Merge pull request #899 from ae-utbm/ajax-select

Improve ajax select
This commit is contained in:
thomas girod 2024-11-10 13:37:57 +01:00 committed by GitHub
commit 7cc13ea669
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 1325 additions and 565 deletions

23
accounting/api.py Normal file
View File

@ -0,0 +1,23 @@
from typing import Annotated
from annotated_types import MinLen
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.api_permissions import CanAccessLookup
@api_controller("/lookup", permissions=[CanAccessLookup])
class AccountingController(ControllerBase):
@route.get("/club-account", response=PaginatedResponseSchema[ClubAccountSchema])
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club_account(self, search: Annotated[str, MinLen(1)]):
return ClubAccount.objects.filter(name__icontains=search).values()
@route.get("/company", response=PaginatedResponseSchema[CompanySchema])
@paginate(PageNumberPaginationExtra, page_size=50)
def search_company(self, search: Annotated[str, MinLen(1)]):
return Company.objects.filter(name__icontains=search).values()

15
accounting/schemas.py Normal file
View File

@ -0,0 +1,15 @@
from ninja import ModelSchema
from accounting.models import ClubAccount, Company
class ClubAccountSchema(ModelSchema):
class Meta:
model = ClubAccount
fields = ["id", "name"]
class CompanySchema(ModelSchema):
class Meta:
model = Company
fields = ["id", "name"]

View File

@ -0,0 +1,60 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type ClubAccountSchema,
type CompanySchema,
accountingSearchClubAccount,
accountingSearchCompany,
} from "#openapi";
@registerComponent("club-account-ajax-select")
export class ClubAccountAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await accountingSearchClubAccount({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("company-ajax-select")
export class CompanyAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await accountingSearchCompany({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: CompanySchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: CompanySchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

View File

@ -61,10 +61,10 @@
<script> <script>
$( function() { $( function() {
var target_type = $('#id_target_type'); var target_type = $('#id_target_type');
var user = $('#id_user_wrapper'); var user = $('user-ajax-select');
var club = $('#id_club_wrapper'); var club = $('club-ajax-select');
var club_account = $('#id_club_account_wrapper'); var club_account = $('club-account-ajax-select');
var company = $('#id_company_wrapper'); var company = $('company-ajax-select');
var other = $('#id_target_label'); var other = $('#id_target_label');
var need_link = $('#id_need_link_full'); var need_link = $('#id_need_link_full');
function update_targets () { function update_targets () {

View File

@ -15,7 +15,6 @@
import collections import collections
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
@ -39,6 +38,13 @@ from accounting.models import (
Operation, Operation,
SimplifiedAccountingType, SimplifiedAccountingType,
) )
from accounting.widgets.select import (
AutoCompleteSelectClubAccount,
AutoCompleteSelectCompany,
)
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from core.models import User
from core.views import ( from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
@ -47,6 +53,7 @@ from core.views import (
TabedViewMixin, TabedViewMixin,
) )
from core.views.forms import SelectDate, SelectFile from core.views.forms import SelectDate, SelectFile
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling from counter.models import Counter, Product, Selling
# Main accounting view # Main accounting view
@ -334,12 +341,30 @@ class OperationForm(forms.ModelForm):
"invoice": SelectFile, "invoice": SelectFile,
} }
user = AutoCompleteSelectField("users", help_text=None, required=False) user = forms.ModelChoiceField(
club_account = AutoCompleteSelectField( help_text=None,
"club_accounts", help_text=None, required=False required=False,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
club_account = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClubAccount,
queryset=ClubAccount.objects.all(),
)
club = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClub,
queryset=Club.objects.all(),
)
company = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectCompany,
queryset=Company.objects.all(),
) )
club = AutoCompleteSelectField("clubs", help_text=None, required=False)
company = AutoCompleteSelectField("companies", help_text=None, required=False)
need_link = forms.BooleanField( need_link = forms.BooleanField(
label=_("Link this operation to the target account"), label=_("Link this operation to the target account"),
required=False, required=False,
@ -817,8 +842,12 @@ class LabelDeleteView(CanEditMixin, DeleteView):
class CloseCustomerAccountForm(forms.Form): class CloseCustomerAccountForm(forms.Form):
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", label=_("Refound this account"), help_text=None, required=True label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )

View File

@ -0,0 +1,39 @@
from pydantic import TypeAdapter
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
_js = ["webpack/accounting/components/ajax-select-index.ts"]
class AutoCompleteSelectClubAccount(AutoCompleteSelect):
component_name = "club-account-ajax-select"
model = ClubAccount
adapter = TypeAdapter(list[ClubAccountSchema])
js = _js
class AutoCompleteSelectMultipleClubAccount(AutoCompleteSelectMultiple):
component_name = "club-account-ajax-select"
model = ClubAccount
adapter = TypeAdapter(list[ClubAccountSchema])
js = _js
class AutoCompleteSelectCompany(AutoCompleteSelect):
component_name = "company-ajax-select"
model = Company
adapter = TypeAdapter(list[CompanySchema])
js = _js
class AutoCompleteSelectMultipleCompany(AutoCompleteSelectMultiple):
component_name = "company-ajax-select"
model = Company
adapter = TypeAdapter(list[CompanySchema])
js = _js

22
club/api.py Normal file
View File

@ -0,0 +1,22 @@
from typing import Annotated
from annotated_types import MinLen
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Club
from club.schemas import ClubSchema
from core.api_permissions import CanAccessLookup
@api_controller("/club")
class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[ClubSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, search: Annotated[str, MinLen(1)]):
return Club.objects.filter(name__icontains=search).values()

View File

@ -22,7 +22,6 @@
# #
# #
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -30,6 +29,7 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectMultipleUser
from counter.models import Counter from counter.models import Counter
@ -50,11 +50,12 @@ class MailingForm(forms.Form):
ACTION_NEW_SUBSCRIPTION = 2 ACTION_NEW_SUBSCRIPTION = 2
ACTION_REMOVE_SUBSCRIPTION = 3 ACTION_REMOVE_SUBSCRIPTION = 3
subscription_users = AutoCompleteSelectMultipleField( subscription_users = forms.ModelMultipleChoiceField(
"users",
label=_("Users to add"), label=_("Users to add"),
help_text=_("Search users to add (one or more)."), help_text=_("Search users to add (one or more)."),
required=False, required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
) )
def __init__(self, club_id, user_id, mailings, *args, **kwargs): def __init__(self, club_id, user_id, mailings, *args, **kwargs):
@ -111,12 +112,7 @@ class MailingForm(forms.Form):
"""Convert given users into real users and check their validity.""" """Convert given users into real users and check their validity."""
cleaned_data = super().clean() cleaned_data = super().clean()
users = [] users = []
for user_id in cleaned_data["subscription_users"]: for user in cleaned_data["subscription_users"]:
user = User.objects.filter(id=user_id).first()
if not user:
raise forms.ValidationError(
_("One of the selected users doesn't exist"), code="invalid"
)
if not user.email: if not user.email:
raise forms.ValidationError( raise forms.ValidationError(
_("One of the selected users doesn't have an email address"), _("One of the selected users doesn't have an email address"),
@ -180,11 +176,12 @@ class ClubMemberForm(forms.Form):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
users = AutoCompleteSelectMultipleField( users = forms.ModelMultipleChoiceField(
"users",
label=_("Users to add"), label=_("Users to add"),
help_text=_("Search users to add (one or more)."), help_text=_("Search users to add (one or more)."),
required=False, required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -238,12 +235,7 @@ class ClubMemberForm(forms.Form):
""" """
cleaned_data = super().clean() cleaned_data = super().clean()
users = [] users = []
for user_id in cleaned_data["users"]: for user in cleaned_data["users"]:
user = User.objects.filter(id=user_id).first()
if not user:
raise forms.ValidationError(
_("One of the selected users doesn't exist"), code="invalid"
)
if not user.is_subscribed: if not user.is_subscribed:
raise forms.ValidationError( raise forms.ValidationError(
_("User must be subscriber to take part to a club"), code="invalid" _("User must be subscriber to take part to a club"), code="invalid"

9
club/schemas.py Normal file
View File

@ -0,0 +1,9 @@
from ninja import ModelSchema
from club.models import Club
class ClubSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name"]

View File

@ -0,0 +1,30 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
export class ClubAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ClubSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ClubSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

View File

@ -254,7 +254,7 @@ class TestClubModel(TestClub):
self.client.force_login(self.root) self.client.force_login(self.root)
response = self.client.post( response = self.client.post(
self.members_url, self.members_url,
{"users": self.subscriber.id, "role": 3}, {"users": [self.subscriber.id], "role": 3},
) )
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
@ -266,7 +266,7 @@ class TestClubModel(TestClub):
response = self.client.post( response = self.client.post(
self.members_url, self.members_url,
{ {
"users": f"|{self.subscriber.id}|{self.krophil.id}|", "users": (self.subscriber.id, self.krophil.id),
"role": 3, "role": 3,
}, },
) )
@ -330,7 +330,7 @@ class TestClubModel(TestClub):
response = self.client.post( response = self.client.post(
self.members_url, self.members_url,
{ {
"users": f"|{self.subscriber.id}|{9999}|", "users": (self.subscriber.id, 9999),
"start_date": "12/06/2016", "start_date": "12/06/2016",
"role": 3, "role": 3,
}, },
@ -629,7 +629,7 @@ class TestMailingForm(TestCase):
self.mail_url, self.mail_url,
{ {
"action": MailingForm.ACTION_NEW_SUBSCRIPTION, "action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|%s|%s|" % (self.comunity.id, self.rbatsbak.id), "subscription_users": (self.comunity.id, self.rbatsbak.id),
"subscription_mailing": Mailing.objects.get(email="mde").id, "subscription_mailing": Mailing.objects.get(email="mde").id,
}, },
) )
@ -715,16 +715,17 @@ class TestMailingForm(TestCase):
self.mail_url, self.mail_url,
{ {
"action": MailingForm.ACTION_NEW_SUBSCRIPTION, "action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|789|", "subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id, "subscription_mailing": Mailing.objects.get(email="mde").id,
}, },
) )
assert response.status_code == 200 assert response.status_code == 200
self.assertInHTML( self.assertInHTML(
_("One of the selected users doesn't exist"), response.content.decode() _("You must specify at least an user or an email address"),
response.content.decode(),
) )
# An user has no email adress # An user has no email address
self.krophil.email = "" self.krophil.email = ""
self.krophil.save() self.krophil.save()
@ -782,8 +783,11 @@ class TestMailingForm(TestCase):
self.mail_url, self.mail_url,
{ {
"action": MailingForm.ACTION_NEW_SUBSCRIPTION, "action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|%s|%s|%s|" "subscription_users": (
% (self.comunity.id, self.rbatsbak.id, self.krophil.id), self.comunity.id,
self.rbatsbak.id,
self.krophil.id,
),
"subscription_mailing": mde.id, "subscription_mailing": mde.id,
}, },
) )

23
club/widgets/select.py Normal file
View File

@ -0,0 +1,23 @@
from pydantic import TypeAdapter
from club.models import Club
from club.schemas import ClubSchema
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
_js = ["webpack/club/components/ajax-select-index.ts"]
class AutoCompleteSelectClub(AutoCompleteSelect):
component_name = "club-ajax-select"
model = Club
adapter = TypeAdapter(list[ClubSchema])
js = _js
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple):
component_name = "club-ajax-select"
model = Club
adapter = TypeAdapter(list[ClubSchema])
js = _js

View File

@ -51,7 +51,8 @@ from core.views import (
QuickNotifMixin, QuickNotifMixin,
TabedViewMixin, TabedViewMixin,
) )
from core.views.forms import MarkdownInput, SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object

View File

@ -11,11 +11,16 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing from club.models import Mailing
from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot from core.api_permissions import (
from core.models import User CanAccessLookup,
CanView,
)
from core.models import Group, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema, MarkdownSchema,
SithFileSchema,
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
@ -44,7 +49,7 @@ class MailingListController(ControllerBase):
return data return data
@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter]) @api_controller("/user", permissions=[CanAccessLookup])
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema]) @route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
@ -62,6 +67,30 @@ class UserController(ControllerBase):
) )
@api_controller("/file")
class SithFileController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SithFileSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@api_controller("/group")
class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4 DEFAULT_DEPTH = 4

View File

@ -127,9 +127,12 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter.""" """Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
if "/counter/" not in request.META["HTTP_REFERER"]: if "/counter/" not in request.META.get("HTTP_REFERER", ""):
return False return False
token = request.session.get("counter_token") token = request.session.get("counter_token")
if not token: if not token:
return False return False
return Counter.objects.filter(token=token).exists() return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter

View File

@ -1,141 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from ajax_select import LookupChannel, register
from django.core.exceptions import PermissionDenied
from accounting.models import ClubAccount, Company
from club.models import Club
from core.models import Group, SithFile, User
from core.views.site import search_user
from counter.models import Counter, Customer, Product
from counter.utils import is_logged_in_counter
class RightManagedLookupChannel(LookupChannel):
def check_auth(self, request):
if not request.user.was_subscribed and not is_logged_in_counter(request):
raise PermissionDenied
@register("users")
class UsersLookup(RightManagedLookupChannel):
model = User
def get_query(self, q, request):
return search_user(q)
def format_match(self, obj):
return obj.get_mini_item()
def format_item_display(self, item):
return item.get_display_name()
@register("customers")
class CustomerLookup(RightManagedLookupChannel):
model = Customer
def get_query(self, q, request):
return list(Customer.objects.filter(user__in=search_user(q)))
def format_match(self, obj):
return obj.user.get_mini_item()
def format_item_display(self, obj):
return f"{obj.user.get_display_name()} ({obj.account_id})"
@register("groups")
class GroupsLookup(RightManagedLookupChannel):
model = Group
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_match(self, obj):
return obj.name
def format_item_display(self, item):
return item.name
@register("clubs")
class ClubLookup(RightManagedLookupChannel):
model = Club
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_match(self, obj):
return obj.name
def format_item_display(self, item):
return item.name
@register("counters")
class CountersLookup(RightManagedLookupChannel):
model = Counter
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_item_display(self, item):
return item.name
@register("products")
class ProductsLookup(RightManagedLookupChannel):
model = Product
def get_query(self, q, request):
return (
self.model.objects.filter(name__icontains=q)
| self.model.objects.filter(code__icontains=q)
).filter(archived=False)[:50]
def format_item_display(self, item):
return "%s (%s)" % (item.name, item.code)
@register("files")
class SithFileLookup(RightManagedLookupChannel):
model = SithFile
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
@register("club_accounts")
class ClubAccountLookup(RightManagedLookupChannel):
model = ClubAccount
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_item_display(self, item):
return item.name
@register("companies")
class CompaniesLookup(RightManagedLookupChannel):
model = Company
def get_query(self, q, request):
return self.model.objects.filter(name__icontains=q)[:50]
def format_item_display(self, item):
return item.name

View File

@ -1,3 +1,4 @@
from pathlib import Path
from typing import Annotated from typing import Annotated
from annotated_types import MinLen from annotated_types import MinLen
@ -8,7 +9,7 @@ from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from core.models import User from core.models import Group, SithFile, User
class SimpleUserSchema(ModelSchema): class SimpleUserSchema(ModelSchema):
@ -45,6 +46,24 @@ class UserProfileSchema(ModelSchema):
return obj.profile_pict.get_download_url() return obj.profile_pict.get_download_url()
class SithFileSchema(ModelSchema):
class Meta:
model = SithFile
fields = ["id", "name"]
path: str
@staticmethod
def resolve_path(obj: SithFile) -> str:
return str(Path(obj.get_parent_path()) / obj.name)
class GroupSchema(ModelSchema):
class Meta:
model = Group
fields = ["id", "name"]
class UserFilterSchema(FilterSchema): class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] search: Annotated[str, MinLen(1)]
exclude: list[int] | None = Field( exclude: list[int] | None = Field(

View File

@ -0,0 +1,45 @@
/* This also requires ajax-select-index.css */
.ts-dropdown {
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
img {
height: 40px;
width: 40px;
object-fit: cover;
border-radius: 50%;
}
}
}
.ts-wrapper {
margin: 5px;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
border-left: 1px solid #aaa;
}
.ts-wrapper.multi .ts-control {
[data-value],
[data-value].active {
background-image: none;
cursor: pointer;
background-color: #e4e4e4;
border: 1px solid #aaa;
border-radius: 4px;
display: inline-block;
margin-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
padding-right: 10px;
padding-left: 10px;
text-shadow: none;
box-shadow: none;
}
}

View File

@ -712,63 +712,6 @@ a:not(.button) {
} }
} }
.tomselected {
margin: 10px 0 !important;
max-width: 100%;
min-width: 100%;
ul {
margin: 0;
}
textarea {
background-color: inherit;
}
.select2-container--default {
color: black;
}
}
.ts-dropdown {
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
img {
height: 40px;
width: 40px;
object-fit: cover;
border-radius: 50%;
}
}
}
.ts-control {
.item {
.fa-times {
margin-left: 5px;
margin-right: 5px;
}
cursor: pointer;
background-color: #e4e4e4;
border: 1px solid #aaa;
border-radius: 4px;
display: inline-block;
margin-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
padding-right: 10px;
}
}
#news_details { #news_details {
display: inline-block; display: inline-block;
margin-top: 20px; margin-top: 20px;

View File

@ -1,93 +0,0 @@
import "tom-select/dist/css/tom-select.css";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type { TomItem, TomLoadCallback, TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type UserProfileSchema, userSearchUsers } from "#openapi";
@registerComponent("ajax-select")
export class AjaxSelect extends inheritHtmlElement("select") {
public widget: TomSelect;
public filter?: <T>(items: T[]) => T[];
constructor() {
super();
window.addEventListener("DOMContentLoaded", () => {
this.loadTomSelect();
});
}
loadTomSelect() {
const minCharNumberForSearch = 2;
let maxItems = 1;
if (this.node.multiple) {
maxItems = Number.parseInt(this.node.dataset.max) ?? null;
}
this.widget = new TomSelect(this.node, {
hideSelected: true,
diacritics: true,
duplicates: false,
maxItems: maxItems,
loadThrottle: Number.parseInt(this.node.dataset.delay) ?? null,
valueField: "id",
labelField: "display_name",
searchField: ["display_name", "nick_name", "first_name", "last_name"],
placeholder: this.node.dataset.placeholder ?? "",
shouldLoad: (query: string) => {
return query.length >= minCharNumberForSearch; // Avoid launching search with less than 2 characters
},
load: (query: string, callback: TomLoadCallback) => {
userSearchUsers({
query: {
search: query,
},
}).then((response) => {
if (response.data) {
if (this.filter) {
callback(this.filter(response.data.results), []);
} else {
callback(response.data.results, []);
}
return;
}
callback([], []);
});
},
render: {
option: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
},
item: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<span><i class="fa fa-times"></i>${sanitize(item.display_name)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
},
});
// Allow removing selected items by clicking on them
this.widget.on("item_select", (item: TomItem) => {
this.widget.removeItem(item);
});
// Remove typed text once an item has been selected
this.widget.on("item_add", () => {
this.widget.setTextboxValue("");
});
}
}

View File

@ -0,0 +1,183 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type {
RecursivePartial,
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
"delay",
"placeholder",
"max",
"min-characters-for-search",
];
public widget: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
protected placeholder = "";
protected max: number | null = null;
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue) ?? null;
break;
}
case "placeholder": {
this.placeholder = newValue ?? "";
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break;
}
default: {
return;
}
}
}
connectedCallback() {
super.connectedCallback();
this.widget = new TomSelect(this.node, this.tomSelectSettings());
this.attachBehaviors();
}
protected shouldLoad(query: string) {
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than setup number of characters
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
plugins: {
// biome-ignore lint/style/useNamingConvention: this is required by the api
remove_button: {
title: gettext("Remove"),
},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
option: (item: TomOption, sanitize: typeof escape_html) => {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.text)}</span>
</div>`;
},
item: (item: TomOption, sanitize: typeof escape_html) => {
return `<span>${sanitize(item.text)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
},
};
}
protected attachBehaviors() {
/* Called once the widget has been initialized */
}
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
protected abstract valueField: string;
protected abstract labelField: string;
protected abstract searchField: string[];
protected abstract renderOption(
item: TomOption,
sanitize: typeof escape_html,
): string;
protected abstract renderItem(item: TomOption, sanitize: typeof escape_html): string;
protected abstract search(query: string): Promise<TomOption[]>;
private initialValues: TomOption[] = [];
public setFilter(filter?: (items: TomOption[]) => TomOption[]) {
this.filter = filter;
}
protected shouldLoad(query: string) {
const resp = super.shouldLoad(query);
/* Force order sync with backend if no client side filtering is set */
if (!resp && this.searchField.length === 0) {
this.widget.clearOptions();
}
return resp;
}
protected async loadFunction(query: string, callback: TomLoadCallback) {
/* Force order sync with backend if no client side filtering is set */
if (this.searchField.length === 0) {
this.widget.clearOptions();
}
const resp = await this.search(query);
if (this.filter) {
callback(this.filter(resp), []);
} else {
callback(resp, []);
}
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
...super.tomSelectSettings(),
hideSelected: true,
diacritics: true,
duplicates: false,
valueField: this.valueField,
labelField: this.labelField,
searchField: this.searchField,
load: (query: string, callback: TomLoadCallback) =>
this.loadFunction(query, callback), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
...super.tomSelectSettings().render,
option: this.renderOption,
item: this.renderItem,
},
};
}
connectedCallback() {
/* Capture initial values before they get moved to the inner node and overridden by tom-select */
const initial = this.querySelector("slot[name='initial']")?.textContent;
this.initialValues = initial ? JSON.parse(initial) : [];
super.connectedCallback();
}
protected attachBehaviors() {
super.attachBehaviors();
// Gather selected options, they must be added with slots like `<slot>json</slot>`
for (const value of this.initialValues) {
this.widget.addOption(value, false);
this.widget.addItem(value[this.valueField]);
}
}
}

View File

@ -0,0 +1,100 @@
import "tom-select/dist/css/tom-select.default.css";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type GroupSchema,
type SithFileSchema,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
@registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {}
@registerComponent("user-ajax-select")
export class UserAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "display_name";
protected searchField: string[] = []; // Disable local search filter and rely on tested backend
protected async search(query: string): Promise<TomOption[]> {
const resp = await userSearchUsers({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
}
protected renderItem(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.display_name)}</span>`;
}
}
@registerComponent("group-ajax-select")
export class GroupsAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await groupSearchGroup({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: GroupSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: GroupSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("sith-file-ajax-select")
export class SithFileAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "path";
protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await sithfileSearchFiles({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: SithFileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span>
</div>`;
}
protected renderItem(item: SithFileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`;
}
}

View File

@ -13,8 +13,13 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
element: textarea, element: textarea,
spellChecker: false, spellChecker: false,
autoDownloadFontAwesome: false, autoDownloadFontAwesome: false,
previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => { previewRender: (plainText: string, preview: MarkdownInput) => {
const func = async (plainText: string, preview: MarkdownInput): Promise<null> => { /* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = ( preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } }) await markdownRenderMarkdown({ body: { text: plainText } })
).data as string; ).data as string;
@ -22,7 +27,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}; };
func(plainText, preview); func(plainText, preview);
return null; return null;
}, 300), }, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
toolbar: [ toolbar: [
{ {
@ -185,8 +191,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
@registerComponent("markdown-input") @registerComponent("markdown-input")
class MarkdownInput extends inheritHtmlElement("textarea") { class MarkdownInput extends inheritHtmlElement("textarea") {
constructor() { connectedCallback() {
super(); super.connectedCallback();
window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node)); loadEasyMde(this.node);
} }
} }

View File

@ -0,0 +1,33 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
/**
* Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("link-once")
export class LinkOnce extends inheritHtmlElement("link") {
connectedCallback() {
super.connectedCallback(false);
// We get href from node.attributes instead of node.href to avoid getting the domain part
const href = this.node.attributes.getNamedItem("href").nodeValue;
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
this.appendChild(this.node);
}
}
}
/**
* Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("script-once")
export class ScriptOnce extends inheritHtmlElement("script") {
connectedCallback() {
super.connectedCallback(false);
// We get src from node.attributes instead of node.src to avoid getting the domain part
const src = this.node.attributes.getNamedItem("src").nodeValue;
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
this.appendChild(this.node);
}
}
}

View File

@ -1,14 +1,14 @@
import type { Client, Options, RequestResult } from "@hey-api/client-fetch"; import type { Client, Options, RequestResult } from "@hey-api/client-fetch";
import { client } from "#openapi"; import { client } from "#openapi";
interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
count: number; count: number;
next: string | null; next: string | null;
previous: string | null; previous: string | null;
results: T[]; results: T[];
} }
interface PaginatedRequest { export interface PaginatedRequest {
query?: { query?: {
page?: number; page?: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case

View File

@ -30,8 +30,7 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
return class Inherited extends HTMLElement { return class Inherited extends HTMLElement {
protected node: HTMLElementTagNameMap[K]; protected node: HTMLElementTagNameMap[K];
constructor() { connectedCallback(autoAddNode?: boolean) {
super();
this.node = document.createElement(tagName); this.node = document.createElement(tagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) { for (const attr of this.attributes) {
@ -44,7 +43,14 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
this.removeAttributeNode(attr); this.removeAttributeNode(attr);
this.node.setAttributeNode(attr); this.node.setAttributeNode(attr);
} }
this.node.innerHTML = this.innerHTML;
this.innerHTML = "";
// Automatically add node to DOM if autoAddNode is true or unspecified
if (autoAddNode === undefined || autoAddNode) {
this.appendChild(this.node); this.appendChild(this.node);
} }
}
}; };
} }

View File

@ -7,7 +7,6 @@
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}"> <link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}"> <link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.scss') }}"> <link rel="stylesheet" href="{{ static('core/header.scss') }}">
@ -21,6 +20,8 @@
<link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript> <noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script src={{ static("webpack/core/components/include-index.ts") }}></script>
<script src="{{ static('webpack/alpine-index.js') }}" defer></script> <script src="{{ static('webpack/alpine-index.js') }}" defer></script>
<!-- Jquery declared here to be accessible in every django widgets --> <!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('webpack/jquery-index.js') }}"></script> <script src="{{ static('webpack/jquery-index.js') }}"></script>
@ -301,8 +302,6 @@
{% endif %} {% endif %}
{% block script %} {% block script %}
<script src="{{ static('ajax_select/js/ajax_select.js') }}"></script>
<script src="{{ url('javascript-catalog') }}"></script>
<script> <script>
function showMenu() { function showMenu() {
let navbar = document.getElementById("navbar-content"); let navbar = document.getElementById("navbar-content");

View File

@ -0,0 +1,23 @@
{% for js in statics.js %}
<script-once src="{{ js }}" defer></script-once>
{% endfor %}
{% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %}
<{{ component }} name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
{% if group_name %}
<optgroup label="{{ group_name }}">
{% endif %}
{% for widget in group_choices %}
{% include widget.template_name %}
{% endfor %}
{% if group_name %}
</optgroup>
{% endif %}
{% endfor %}
{% if initial %}
<slot style="display:none" name="initial">{{ initial }}</slot>
{% endif %}
</{{ component }}>

View File

@ -1,7 +1,7 @@
<div> <div>
<script-once src="{{ statics.js }}" defer></script-once>
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input> <markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
{# The easymde script can be included twice, it's safe in the code #} </div>
<script src="{{ statics.js }}" defer> </script>
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
</div>

View File

@ -18,7 +18,6 @@ from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from ajax_select import make_ajax_field
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -39,6 +38,11 @@ from core.views import (
CanViewMixin, CanViewMixin,
can_view, can_view,
) )
from core.views.widgets.select import (
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile,
AutoCompleteSelectUser,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@ -217,14 +221,13 @@ class FileEditPropForm(forms.ModelForm):
class Meta: class Meta:
model = SithFile model = SithFile
fields = ["parent", "owner", "edit_groups", "view_groups"] fields = ["parent", "owner", "edit_groups", "view_groups"]
widgets = {
"parent": AutoCompleteSelectSithFile,
"owner": AutoCompleteSelectUser,
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
}
parent = make_ajax_field(SithFile, "parent", "files", help_text="")
edit_groups = make_ajax_field(
SithFile, "edit_groups", "groups", help_text="", label=_("edit group")
)
view_groups = make_ajax_field(
SithFile, "view_groups", "groups", help_text="", label=_("view group")
)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)

View File

@ -23,20 +23,16 @@
import re import re
from io import BytesIO from io import BytesIO
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.forms import ( from django.forms import (
CheckboxSelectMultiple, CheckboxSelectMultiple,
DateInput, DateInput,
DateTimeInput, DateTimeInput,
Textarea,
TextInput, TextInput,
) )
from django.utils.translation import gettext from django.utils.translation import gettext
@ -47,6 +43,12 @@ from PIL import Image
from antispam.forms import AntiSpamEmailField from antispam.forms import AntiSpamEmailField
from core.models import Gift, Page, SithFile, User from core.models import Gift, Page, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectGroup,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
# Widgets # Widgets
@ -65,19 +67,6 @@ class SelectDate(DateInput):
super().__init__(attrs=attrs, format=format or "%Y-%m-%d") super().__init__(attrs=attrs, format=format or "%Y-%m-%d")
class MarkdownInput(Textarea):
template_name = "core/widgets/markdown_textarea.jinja"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["statics"] = {
"js": staticfiles_storage.url("webpack/easymde-index.ts"),
"css": staticfiles_storage.url("webpack/easymde-index.css"),
}
return context
class NFCTextInput(TextInput): class NFCTextInput(TextInput):
template_name = "core/widgets/nfc.jinja" template_name = "core/widgets/nfc.jinja"
@ -311,8 +300,12 @@ class UserGodfathersForm(forms.Form):
], ],
label=_("Add"), label=_("Add"),
) )
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", required=True, label=_("Select user"), help_text="" label=_("Select user"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )
def __init__(self, *args, user: User, **kwargs): def __init__(self, *args, user: User, **kwargs):
@ -354,13 +347,12 @@ class PagePropForm(forms.ModelForm):
class Meta: class Meta:
model = Page model = Page
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
widgets = {
edit_groups = make_ajax_field( "parent": AutoCompleteSelect,
Page, "edit_groups", "groups", help_text="", label=_("edit groups") "owner_group": AutoCompleteSelectGroup,
) "edit_groups": AutoCompleteSelectMultipleGroup,
view_groups = make_ajax_field( "view_groups": AutoCompleteSelectMultipleGroup,
Page, "view_groups", "groups", help_text="", label=_("view groups") }
)
def __init__(self, *arg, **kwargs): def __init__(self, *arg, **kwargs):
super().__init__(*arg, **kwargs) super().__init__(*arg, **kwargs)
@ -372,13 +364,12 @@ class PageForm(forms.ModelForm):
class Meta: class Meta:
model = Page model = Page
fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"]
widgets = {
edit_groups = make_ajax_field( "parent": AutoCompleteSelect,
Page, "edit_groups", "groups", help_text="", label=_("edit groups") "owner_group": AutoCompleteSelectGroup,
) "edit_groups": AutoCompleteSelectMultipleGroup,
view_groups = make_ajax_field( "view_groups": AutoCompleteSelectMultipleGroup,
Page, "view_groups", "groups", help_text="", label=_("view groups") }
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -15,7 +15,6 @@
"""Views to manage Groups.""" """Views to manage Groups."""
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -24,6 +23,9 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import RealGroup, User from core.models import RealGroup, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView from core.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views.widgets.select import (
AutoCompleteSelectMultipleUser,
)
# Forms # Forms
@ -34,6 +36,15 @@ class EditMembersForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.current_users = kwargs.pop("users", []) self.current_users = kwargs.pop("users", [])
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["users_added"] = forms.ModelMultipleChoiceField(
label=_("Users to add to group"),
help_text=_("Search users to add (one or more)."),
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.exclude(id__in=self.current_users).all(),
)
self.fields["users_removed"] = forms.ModelMultipleChoiceField( self.fields["users_removed"] = forms.ModelMultipleChoiceField(
User.objects.filter(id__in=self.current_users).all(), User.objects.filter(id__in=self.current_users).all(),
label=_("Users to remove from group"), label=_("Users to remove from group"),
@ -41,31 +52,6 @@ class EditMembersForm(forms.Form):
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
) )
users_added = AutoCompleteSelectMultipleField(
"users",
label=_("Users to add to group"),
help_text=_("Search users to add (one or more)."),
required=False,
)
def clean_users_added(self):
"""Check that the user is not trying to add an user already in the group."""
cleaned_data = super().clean()
users_added = cleaned_data.get("users_added", None)
if not users_added:
return users_added
current_users = [
str(id_) for id_ in self.current_users.values_list("id", flat=True)
]
for user in users_added:
if user in current_users:
raise forms.ValidationError(
_("You can not add the same user twice"), code="invalid"
)
return users_added
# Views # Views
@ -110,8 +96,10 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
data = form.clean() data = form.clean()
group = self.get_object() group = self.get_object()
if data["users_removed"]:
for user in data["users_removed"]: for user in data["users_removed"]:
group.users.remove(user) group.users.remove(user)
if data["users_added"]:
for user in data["users_added"]: for user in data["users_added"]:
group.users.add(user) group.users.add(user)
group.save() group.save()

View File

@ -23,7 +23,8 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import LockError, Page, PageRev from core.models import LockError, Page, PageRev
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from core.views.forms import MarkdownInput, PageForm, PagePropForm from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput
class CanEditPagePropMixin(CanEditPropMixin): class CanEditPagePropMixin(CanEditPropMixin):

View File

@ -0,0 +1,15 @@
from django.contrib.staticfiles.storage import staticfiles_storage
from django.forms import Textarea
class MarkdownInput(Textarea):
template_name = "core/widgets/markdown_textarea.jinja"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["statics"] = {
"js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"),
"css": staticfiles_storage.url("webpack/core/components/easymde-index.css"),
}
return context

View File

@ -0,0 +1,111 @@
from collections.abc import Collection
from typing import Any
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Model, QuerySet
from django.forms import Select, SelectMultiple
from ninja import ModelSchema
from pydantic import TypeAdapter
from core.models import Group, SithFile, User
from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema
class AutoCompleteSelectMixin:
component_name = "autocomplete-select"
template_name = "core/widgets/autocomplete_select.jinja"
model: type[Model] | None = None
adapter: TypeAdapter[Collection[ModelSchema]] | None = None
pk = "id"
js = [
"webpack/core/components/ajax-select-index.ts",
]
css = [
"webpack/core/components/ajax-select-index.css",
"core/components/ajax-select.scss",
]
def get_queryset(self, pks: Collection[Any]) -> QuerySet:
return self.model.objects.filter(
**{
f"{self.pk}__in": [
pk
for pk in pks
if str(pk).isdigit() # We filter empty values for create views
]
}
).all()
def __init__(self, attrs=None, choices=()):
if self.is_ajax:
choices = () # Avoid computing anything when in ajax mode
super().__init__(attrs=attrs, choices=choices)
@property
def is_ajax(self):
return self.adapter and self.model
def optgroups(self, name, value, attrs=None):
"""Don't create option groups when doing ajax"""
if self.is_ajax:
return []
return super().optgroups(name, value, attrs=attrs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["widget"]["attrs"]["autocomplete"] = "off"
context["component"] = self.component_name
context["statics"] = {
"js": [staticfiles_storage.url(file) for file in self.js],
"css": [staticfiles_storage.url(file) for file in self.css],
}
if self.is_ajax:
context["initial"] = self.adapter.dump_json(
self.adapter.validate_python(
self.get_queryset(context["widget"]["value"])
)
).decode("utf-8")
return context
class AutoCompleteSelect(AutoCompleteSelectMixin, Select): ...
class AutoCompleteSelectMultiple(AutoCompleteSelectMixin, SelectMultiple): ...
class AutoCompleteSelectUser(AutoCompleteSelect):
component_name = "user-ajax-select"
model = User
adapter = TypeAdapter(list[UserProfileSchema])
class AutoCompleteSelectMultipleUser(AutoCompleteSelectMultiple):
component_name = "user-ajax-select"
model = User
adapter = TypeAdapter(list[UserProfileSchema])
class AutoCompleteSelectGroup(AutoCompleteSelect):
component_name = "group-ajax-select"
model = Group
adapter = TypeAdapter(list[GroupSchema])
class AutoCompleteSelectMultipleGroup(AutoCompleteSelectMultiple):
component_name = "group-ajax-select"
model = Group
adapter = TypeAdapter(list[GroupSchema])
class AutoCompleteSelectSithFile(AutoCompleteSelect):
component_name = "sith-file-ajax-select"
model = SithFile
adapter = TypeAdapter(list[SithFileSchema])
class AutoCompleteSelectMultipleSithFile(AutoCompleteSelectMultiple):
component_name = "sith-file-ajax-select"
model = SithFile
adapter = TypeAdapter(list[SithFileSchema])

View File

@ -12,11 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from ninja_extra import ControllerBase, api_controller, route from typing import Annotated
from core.api_permissions import CanView, IsRoot from annotated_types import MinLen
from counter.models import Counter from django.db.models import Q
from counter.schemas import CounterSchema from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot
from counter.models import Counter, Product
from counter.schemas import (
CounterFilterSchema,
CounterSchema,
ProductSchema,
SimplifiedCounterSchema,
)
@api_controller("/counter") @api_controller("/counter")
@ -37,3 +49,30 @@ class CounterController(ControllerBase):
for c in counters: for c in counters:
self.check_object_permissions(c) self.check_object_permissions(c)
return counters return counters
@route.get(
"/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_counter(self, filters: Query[CounterFilterSchema]):
return filters.filter(Counter.objects.all())
@api_controller("/product")
class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[ProductSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_products(self, search: Annotated[str, MinLen(1)]):
return (
Product.objects.filter(
Q(name__icontains=search) | Q(code__icontains=search)
)
.filter(archived=False)
.values()
)

View File

@ -1,10 +1,15 @@
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.widgets.select import AutoCompleteSelectClub
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter, Counter,
@ -14,6 +19,11 @@ from counter.models import (
Refilling, Refilling,
StudentCard, StudentCard,
) )
from counter.widgets.select import (
AutoCompleteSelectMultipleCounter,
AutoCompleteSelectMultipleProduct,
AutoCompleteSelectProduct,
)
class BillingInfoForm(forms.ModelForm): class BillingInfoForm(forms.ModelForm):
@ -68,8 +78,11 @@ class GetUserForm(forms.Form):
required=False, required=False,
widget=NFCTextInput, widget=NFCTextInput,
) )
id = AutoCompleteSelectField( id = forms.CharField(
"users", required=False, label=_("Select user"), help_text=None label=_("Select user"),
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
) )
def as_p(self): def as_p(self):
@ -122,8 +135,10 @@ class CounterEditForm(forms.ModelForm):
model = Counter model = Counter
fields = ["sellers", "products"] fields = ["sellers", "products"]
sellers = make_ajax_field(Counter, "sellers", "users", help_text="") widgets = {
products = make_ajax_field(Counter, "products", "products", help_text="") "sellers": AutoCompleteSelectMultipleUser,
"products": AutoCompleteSelectMultipleProduct,
}
class ProductEditForm(forms.ModelForm): class ProductEditForm(forms.ModelForm):
@ -145,44 +160,37 @@ class ProductEditForm(forms.ModelForm):
"tray", "tray",
"archived", "archived",
] ]
widgets = {
"parent_product": AutoCompleteSelectMultipleProduct,
"product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub,
}
parent_product = AutoCompleteSelectField( counters = forms.ModelMultipleChoiceField(
"products", show_help_text=False, label=_("Parent product"), required=False help_text=None,
)
buying_groups = AutoCompleteSelectMultipleField(
"groups",
show_help_text=False,
help_text="",
label=_("Buying groups"),
required=True,
)
club = AutoCompleteSelectField("clubs", show_help_text=False)
counters = AutoCompleteSelectMultipleField(
"counters",
show_help_text=False,
help_text="",
label=_("Counters"), label=_("Counters"),
required=False, required=False,
widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = [ self.fields["counters"].initial = self.instance.counters.all()
str(c.id) for c in self.instance.counters.all()
]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
if self.fields["counters"].initial: if self.fields["counters"].initial:
for cid in self.fields["counters"].initial: # Remove the product from all counter it was added to
c = Counter.objects.filter(id=int(cid)).first() # It will then only be added to selected counters
c.products.remove(self.instance) for counter in self.fields["counters"].initial:
c.save() counter.products.remove(self.instance)
for cid in self.cleaned_data["counters"]: counter.save()
c = Counter.objects.filter(id=int(cid)).first() for counter in self.cleaned_data["counters"]:
c.products.add(self.instance) counter.products.add(self.instance)
c.save() counter.save()
return ret return ret
@ -199,8 +207,7 @@ class EticketForm(forms.ModelForm):
class Meta: class Meta:
model = Eticket model = Eticket
fields = ["product", "banner", "event_title", "event_date"] fields = ["product", "banner", "event_title", "event_date"]
widgets = {"event_date": SelectDate} widgets = {
"product": AutoCompleteSelectProduct,
product = AutoCompleteSelectField( "event_date": SelectDate,
"products", show_help_text=False, label=_("Product"), required=True }
)

View File

@ -1,7 +1,10 @@
from ninja import ModelSchema from typing import Annotated
from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema
from core.schemas import SimpleUserSchema from core.schemas import SimpleUserSchema
from counter.models import Counter from counter.models import Counter, Product
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
@ -11,3 +14,19 @@ class CounterSchema(ModelSchema):
class Meta: class Meta:
model = Counter model = Counter
fields = ["id", "name", "type", "club", "products"] fields = ["id", "name", "type", "club", "products"]
class CounterFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains")
class SimplifiedCounterSchema(ModelSchema):
class Meta:
model = Counter
fields = ["id", "name"]
class ProductSchema(ModelSchema):
class Meta:
model = Product
fields = ["id", "name", "code"]

View File

@ -0,0 +1,60 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type CounterSchema,
type ProductSchema,
counterSearchCounter,
productSearchProducts,
} from "#openapi";
@registerComponent("product-ajax-select")
export class ProductAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await productSearchProducts({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
}
}
@registerComponent("counter-ajax-select")
export class CounterAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await counterSearchCounter({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: CounterSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: CounterSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

35
counter/widgets/select.py Normal file
View File

@ -0,0 +1,35 @@
from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from counter.models import Counter, Product
from counter.schemas import ProductSchema, SimplifiedCounterSchema
_js = ["webpack/counter/components/ajax-select-index.ts"]
class AutoCompleteSelectCounter(AutoCompleteSelect):
component_name = "counter-ajax-select"
model = Counter
adapter = TypeAdapter(list[SimplifiedCounterSchema])
js = _js
class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple):
component_name = "counter-ajax-select"
model = Counter
adapter = TypeAdapter(list[SimplifiedCounterSchema])
js = _js
class AutoCompleteSelectProduct(AutoCompleteSelect):
component_name = "product-ajax-select"
model = Product
adapter = TypeAdapter(list[ProductSchema])
js = _js
class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
component_name = "product-ajax-select"
model = Product
adapter = TypeAdapter(list[ProductSchema])
js = _js

View File

@ -1,7 +1,5 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
@ -13,7 +11,13 @@ from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import MarkdownInput, SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role, Vote
if TYPE_CHECKING: if TYPE_CHECKING:
@ -51,11 +55,15 @@ class CandidateForm(forms.ModelForm):
class Meta: class Meta:
model = Candidature model = Candidature
fields = ["user", "role", "program", "election_list"] fields = ["user", "role", "program", "election_list"]
widgets = {"program": MarkdownInput} labels = {
"user": _("User to candidate"),
user = AutoCompleteSelectField( }
"users", label=_("User to candidate"), help_text=None, required=True widgets = {
) "program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None) election_id = kwargs.pop("election_id", None)
@ -97,6 +105,7 @@ class RoleForm(forms.ModelForm):
class Meta: class Meta:
model = Role model = Role
fields = ["title", "election", "description", "max_choice"] fields = ["title", "election", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None) election_id = kwargs.pop("election_id", None)
@ -120,6 +129,7 @@ class ElectionListForm(forms.ModelForm):
class Meta: class Meta:
model = ElectionList model = ElectionList
fields = ("title", "election") fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None) election_id = kwargs.pop("election_id", None)
@ -146,23 +156,12 @@ class ElectionForm(forms.ModelForm):
"vote_groups", "vote_groups",
"candidature_groups", "candidature_groups",
] ]
widgets = {
edit_groups = make_ajax_field( "edit_groups": AutoCompleteSelectMultipleGroup,
Election, "edit_groups", "groups", help_text="", label=_("edit groups") "view_groups": AutoCompleteSelectMultipleGroup,
) "vote_groups": AutoCompleteSelectMultipleGroup,
view_groups = make_ajax_field( "candidature_groups": AutoCompleteSelectMultipleGroup,
Election, "view_groups", "groups", help_text="", label=_("view groups") }
)
vote_groups = make_ajax_field(
Election, "vote_groups", "groups", help_text="", label=_("vote groups")
)
candidature_groups = make_ajax_field(
Election,
"candidature_groups",
"groups",
help_text="",
label=_("candidature groups"),
)
start_date = forms.DateTimeField( start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True label=_("Start date"), widget=SelectDateTime, required=True
@ -328,6 +327,7 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
"""Verify that the selected user is in candidate group.""" """Verify that the selected user is in candidate group."""
obj = form.instance obj = form.instance
obj.election = Election.objects.get(id=self.election.id) obj.election = Election.objects.get(id=self.election.id)
obj.user = obj.user if hasattr(obj, "user") else self.request.user
if (obj.election.can_candidate(obj.user)) and ( if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit obj.user == self.request.user or self.can_edit
): ):

View File

@ -25,7 +25,6 @@ import logging
import math import math
from functools import partial from functools import partial
from ajax_select import make_ajax_field
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -43,6 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from haystack.query import RelatedSearchQuerySet from haystack.query import RelatedSearchQuerySet
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from club.widgets.select import AutoCompleteSelectClub
from core.views import ( from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
@ -50,7 +50,11 @@ from core.views import (
CanViewMixin, CanViewMixin,
can_view, can_view,
) )
from core.views.forms import MarkdownInput from core.views.widgets.markdown import MarkdownInput
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
)
from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic
@ -165,10 +169,15 @@ class ForumForm(forms.ModelForm):
"edit_groups", "edit_groups",
"view_groups", "view_groups",
] ]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"owner_club": AutoCompleteSelectClub,
}
edit_groups = make_ajax_field(Forum, "edit_groups", "groups", help_text="") parent = ForumNameField(
view_groups = make_ajax_field(Forum, "view_groups", "groups", help_text="") Forum.objects.all(), widget=AutoCompleteSelect, required=False
parent = ForumNameField(Forum.objects.all()) )
class ForumCreateView(CanCreateMixin, CreateView): class ForumCreateView(CanCreateMixin, CreateView):

View File

@ -25,7 +25,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
from core.views.forms import MarkdownInput from core.views.widgets.markdown import MarkdownInput
from pedagogy.models import UV, UVComment, UVCommentReport from pedagogy.models import UV, UVComment, UVCommentReport

12
poetry.lock generated
View File

@ -520,16 +520,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"] argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"] bcrypt = ["bcrypt"]
[[package]]
name = "django-ajax-selects"
version = "2.2.1"
description = "Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete."
optional = false
python-versions = "*"
files = [
{file = "django-ajax-selects-2.2.1.tar.gz", hash = "sha256:996ffb38dff1a621b358613afdf2681dbf261e5976da3c30a75e9b08fd81a887"},
]
[[package]] [[package]]
name = "django-countries" name = "django-countries"
version = "7.6.1" version = "7.6.1"
@ -2691,4 +2681,4 @@ filelock = ">=3.4"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0" content-hash = "e64ed169395d2c32672a2f2ad6a40d0910e4a51941b564fbdc505db6332084d2"

View File

@ -30,7 +30,6 @@ django-jinja = "^2.11"
cryptography = "^43.0.0" cryptography = "^43.0.0"
django-phonenumber-field = "^8.0.0" django-phonenumber-field = "^8.0.0"
phonenumbers = "^8.13" phonenumbers = "^8.13"
django-ajax-selects = "^2.2.1"
reportlab = "^4.2" reportlab = "^4.2"
django-haystack = "^3.2.1" django-haystack = "^3.2.1"
xapian-haystack = "^3.0.1" xapian-haystack = "^3.0.1"

View File

@ -23,7 +23,6 @@
# #
import logging import logging
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.urls import reverse from django.urls import reverse
@ -35,6 +34,7 @@ from django.views.generic.edit import FormView
from core.models import OperationLog, SithFile, User from core.models import OperationLog, SithFile, User
from core.views import CanEditPropMixin from core.views import CanEditPropMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Customer from counter.models import Customer
from forum.models import ForumMessageMeta from forum.models import ForumMessageMeta
@ -156,17 +156,29 @@ def delete_all_forum_user_messages(
class MergeForm(forms.Form): class MergeForm(forms.Form):
user1 = AutoCompleteSelectField( user1 = forms.ModelChoiceField(
"users", label=_("User that will be kept"), help_text=None, required=True label=_("User that will be kept"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )
user2 = AutoCompleteSelectField( user2 = forms.ModelChoiceField(
"users", label=_("User that will be deleted"), help_text=None, required=True label=_("User that will be deleted"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )
class SelectUserForm(forms.Form): class SelectUserForm(forms.Form):
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", label=_("User to be selected"), help_text=None, required=True label=_("User to be selected"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )

View File

@ -1,3 +1,6 @@
from typing import Annotated
from annotated_types import MinLen
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.urls import reverse from django.urls import reverse
@ -9,10 +12,11 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.api_permissions import CanView, IsInGroup, IsRoot from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from core.models import Notification, User from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
PictureFilterSchema, PictureFilterSchema,
@ -22,6 +26,18 @@ from sas.schemas import (
IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID) IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID)
@api_controller("/sas/album")
class AlbumController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[AlbumSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_album(self, search: Annotated[str, MinLen(1)]):
return Album.objects.filter(name__icontains=search)
@api_controller("/sas/picture") @api_controller("/sas/picture")
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@route.get( @route.get(

View File

@ -1,14 +1,14 @@
from typing import Any from typing import Any
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
from core.views import MultipleImageField from core.views import MultipleImageField
from core.views.forms import SelectDate from core.views.forms import SelectDate
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest from core.views.widgets.select import AutoCompleteSelectMultipleGroup
from sas.models import Album, Picture, PictureModerationRequest
from sas.widgets.select import AutoCompleteSelectAlbum
class SASForm(forms.Form): class SASForm(forms.Form):
@ -62,34 +62,24 @@ class SASForm(forms.Form):
) )
class RelationForm(forms.ModelForm):
class Meta:
model = PeoplePictureRelation
fields = ["picture"]
widgets = {"picture": forms.HiddenInput}
users = AutoCompleteSelectMultipleField(
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
)
class PictureEditForm(forms.ModelForm): class PictureEditForm(forms.ModelForm):
class Meta: class Meta:
model = Picture model = Picture
fields = ["name", "parent"] fields = ["name", "parent"]
widgets = {"parent": AutoCompleteSelectAlbum}
parent = make_ajax_field(Picture, "parent", "files", help_text="")
class AlbumEditForm(forms.ModelForm): class AlbumEditForm(forms.ModelForm):
class Meta: class Meta:
model = Album model = Album
fields = ["name", "date", "file", "parent", "edit_groups"] fields = ["name", "date", "file", "parent", "edit_groups"]
widgets = {
"parent": AutoCompleteSelectAlbum,
"edit_groups": AutoCompleteSelectMultipleGroup,
}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name")) name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True) date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
parent = make_ajax_field(Album, "parent", "files", help_text="")
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)

View File

@ -1,11 +1,24 @@
from datetime import datetime from datetime import datetime
from pathlib import Path
from django.urls import reverse from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
from core.schemas import SimpleUserSchema, UserProfileSchema from core.schemas import SimpleUserSchema, UserProfileSchema
from sas.models import Picture, PictureModerationRequest from sas.models import Album, Picture, PictureModerationRequest
class AlbumSchema(ModelSchema):
class Meta:
model = Album
fields = ["id", "name"]
path: str
@staticmethod
def resolve_path(obj: Album) -> str:
return str(Path(obj.get_parent_path()) / obj.name)
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):

View File

@ -0,0 +1,30 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type AlbumSchema, albumSearchAlbum } from "#openapi";
@registerComponent("album-ajax-select")
export class AlbumAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "path";
protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await albumSearchAlbum({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span>
</div>`;
}
protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`;
}
}

View File

@ -177,7 +177,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search; this.selector = this.$refs.search;
this.selector.filter = (users: UserProfileSchema[]) => { this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = []; const resp: UserProfileSchema[] = [];
const ids = [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
@ -190,7 +190,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} }
} }
return resp; return resp;
}; });
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId, (i: PictureSchema) => i.id === config.firstPictureId,
); );

View File

@ -1,12 +1,13 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('webpack/ajax-select-index.css') }}"> <link defer rel="stylesheet" href="{{ static('webpack/core/components/ajax-select-index.css') }}">
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}"> <link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script defer src="{{ static('webpack/ajax-select-index.ts') }}"></script> <script defer src="{{ static('webpack/core/components/ajax-select-index.ts') }}"></script>
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script> <script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
{%- endblock -%} {%- endblock -%}
@ -157,12 +158,12 @@
<h5>{% trans %}People{% endtrans %}</h5> <h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %} {% if user.was_subscribed %}
<form @submit.prevent="submitIdentification" x-show="!!selector"> <form @submit.prevent="submitIdentification" x-show="!!selector">
<ajax-select <user-ajax-select
x-ref="search" x-ref="search"
multiple multiple
data-delay="300" delay="300"
data-placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}" placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}"
></ajax-select> ></user-ajax-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% endif %} {% endif %}

26
sas/widgets/select.py Normal file
View File

@ -0,0 +1,26 @@
from pydantic import TypeAdapter
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultiple,
)
from sas.models import Album
from sas.schemas import AlbumSchema
_js = ["webpack/sas/components/ajax-select-index.ts"]
class AutoCompleteSelectAlbum(AutoCompleteSelect):
component_name = "album-ajax-select"
model = Album
adapter = TypeAdapter(list[AlbumSchema])
js = _js
class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple):
component_name = "album-ajax-select"
model = Album
adapter = TypeAdapter(list[AlbumSchema])
js = _js

View File

@ -81,7 +81,6 @@ INSTALLED_APPS = (
"honeypot", "honeypot",
"django_jinja", "django_jinja",
"ninja_extra", "ninja_extra",
"ajax_select",
"haystack", "haystack",
"captcha", "captcha",
"core", "core",

View File

@ -13,7 +13,6 @@
# #
# #
from ajax_select import urls as ajax_select_urls
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
@ -59,7 +58,6 @@ urlpatterns = [
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")), path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")), path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("ajax_select/", include(ajax_select_urls)),
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("captcha/", include("captcha.urls")), path("captcha/", include("captcha.urls")),

View File

@ -15,7 +15,6 @@
import random import random
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
@ -25,6 +24,7 @@ from django.views.generic.edit import CreateView, FormView
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectUser
from subscription.models import Subscription from subscription.models import Subscription
@ -43,11 +43,11 @@ class SubscriptionForm(forms.ModelForm):
class Meta: class Meta:
model = Subscription model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"] fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser}
member = AutoCompleteSelectField("users", required=False, help_text=None)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["member"].required = False
self.fields |= forms.fields_for_model( self.fields |= forms.fields_for_model(
User, User,
fields=["first_name", "last_name", "email", "date_of_birth"], fields=["first_name", "last_name", "email", "date_of_birth"],

View File

@ -24,7 +24,6 @@
from datetime import date from datetime import date
from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -49,6 +48,7 @@ from core.views import (
TabedViewMixin, TabedViewMixin,
) )
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.widgets.select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser
@ -147,8 +147,12 @@ class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView):
class AddUserForm(forms.Form): class AddUserForm(forms.Form):
user = AutoCompleteSelectField( user = forms.ModelChoiceField(
"users", required=True, label=_("Select user"), help_text=None label=_("Select user"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
) )

View File

@ -10,7 +10,7 @@ module.exports = {
.sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)") .sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)")
.reduce((obj, el) => { .reduce((obj, el) => {
// We include the path inside the webpack folder in the name // We include the path inside the webpack folder in the name
const relativePath = []; let relativePath = [];
const fullPath = path.parse(el); const fullPath = path.parse(el);
for (const dir of fullPath.dir.split("/").reverse()) { for (const dir of fullPath.dir.split("/").reverse()) {
if (dir === "webpack") { if (dir === "webpack") {
@ -18,6 +18,8 @@ module.exports = {
} }
relativePath.push(dir); relativePath.push(dir);
} }
// We collected folders in reverse order, we put them back in the original order
relativePath = relativePath.reverse();
relativePath.push(fullPath.name); relativePath.push(fullPath.name);
obj[relativePath.join("/")] = `./${el}`; obj[relativePath.join("/")] = `./${el}`;
return obj; return obj;