diff --git a/accounting/api.py b/accounting/api.py new file mode 100644 index 00000000..a16fb7ab --- /dev/null +++ b/accounting/api.py @@ -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() diff --git a/accounting/schemas.py b/accounting/schemas.py new file mode 100644 index 00000000..3d9edbcc --- /dev/null +++ b/accounting/schemas.py @@ -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"] diff --git a/accounting/static/webpack/accounting/components/ajax-select-index.ts b/accounting/static/webpack/accounting/components/ajax-select-index.ts new file mode 100644 index 00000000..3fc93cf3 --- /dev/null +++ b/accounting/static/webpack/accounting/components/ajax-select-index.ts @@ -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 { + const resp = await accountingSearchClubAccount({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} + +@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 { + const resp = await accountingSearchCompany({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: CompanySchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: CompanySchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} diff --git a/accounting/templates/accounting/operation_edit.jinja b/accounting/templates/accounting/operation_edit.jinja index e8c34364..4a75cb83 100644 --- a/accounting/templates/accounting/operation_edit.jinja +++ b/accounting/templates/accounting/operation_edit.jinja @@ -61,10 +61,10 @@ + @@ -301,8 +302,6 @@ {% endif %} {% block script %} - - - - + {% if widget.value %}{{ widget.value }}{% endif %} + + diff --git a/core/views/files.py b/core/views/files.py index 3df5c014..d70991b7 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -18,7 +18,6 @@ from urllib.parse import quote, urljoin # This file contains all the views that concern the page model from wsgiref.util import FileWrapper -from ajax_select import make_ajax_field from django import forms from django.conf import settings from django.core.exceptions import PermissionDenied @@ -39,6 +38,11 @@ from core.views import ( CanViewMixin, can_view, ) +from core.views.widgets.select import ( + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectSithFile, + AutoCompleteSelectUser, +) from counter.utils import is_logged_in_counter @@ -217,14 +221,13 @@ class FileEditPropForm(forms.ModelForm): class Meta: model = SithFile 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) diff --git a/core/views/forms.py b/core/views/forms.py index 232c938b..cfd1b92c 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -23,20 +23,16 @@ import re from io import BytesIO -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectField from captcha.fields import CaptchaField from django import forms from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, UserCreationForm -from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import ValidationError from django.db import transaction from django.forms import ( CheckboxSelectMultiple, DateInput, DateTimeInput, - Textarea, TextInput, ) from django.utils.translation import gettext @@ -47,6 +43,12 @@ from PIL import Image from antispam.forms import AntiSpamEmailField from core.models import Gift, Page, SithFile, User from core.utils import resize_image +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectGroup, + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectUser, +) # Widgets @@ -65,19 +67,6 @@ class SelectDate(DateInput): 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): template_name = "core/widgets/nfc.jinja" @@ -311,8 +300,12 @@ class UserGodfathersForm(forms.Form): ], label=_("Add"), ) - user = AutoCompleteSelectField( - "users", required=True, label=_("Select user"), help_text="" + user = forms.ModelChoiceField( + label=_("Select user"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) def __init__(self, *args, user: User, **kwargs): @@ -354,13 +347,12 @@ class PagePropForm(forms.ModelForm): class Meta: model = Page fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] - - edit_groups = make_ajax_field( - Page, "edit_groups", "groups", help_text="", label=_("edit groups") - ) - view_groups = make_ajax_field( - Page, "view_groups", "groups", help_text="", label=_("view groups") - ) + widgets = { + "parent": AutoCompleteSelect, + "owner_group": AutoCompleteSelectGroup, + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + } def __init__(self, *arg, **kwargs): super().__init__(*arg, **kwargs) @@ -372,13 +364,12 @@ class PageForm(forms.ModelForm): class Meta: model = Page fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] - - edit_groups = make_ajax_field( - Page, "edit_groups", "groups", help_text="", label=_("edit groups") - ) - view_groups = make_ajax_field( - Page, "view_groups", "groups", help_text="", label=_("view groups") - ) + widgets = { + "parent": AutoCompleteSelect, + "owner_group": AutoCompleteSelectGroup, + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/core/views/group.py b/core/views/group.py index b5fe495e..abb0097f 100644 --- a/core/views/group.py +++ b/core/views/group.py @@ -15,7 +15,6 @@ """Views to manage Groups.""" -from ajax_select.fields import AutoCompleteSelectMultipleField from django import forms from django.urls import reverse_lazy 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.views import CanCreateMixin, CanEditMixin, DetailFormView +from core.views.widgets.select import ( + AutoCompleteSelectMultipleUser, +) # Forms @@ -34,6 +36,15 @@ class EditMembersForm(forms.Form): def __init__(self, *args, **kwargs): self.current_users = kwargs.pop("users", []) 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( User.objects.filter(id__in=self.current_users).all(), label=_("Users to remove from group"), @@ -41,31 +52,6 @@ class EditMembersForm(forms.Form): 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 @@ -110,10 +96,12 @@ class GroupTemplateView(CanEditMixin, DetailFormView): data = form.clean() group = self.get_object() - for user in data["users_removed"]: - group.users.remove(user) - for user in data["users_added"]: - group.users.add(user) + if data["users_removed"]: + for user in data["users_removed"]: + group.users.remove(user) + if data["users_added"]: + for user in data["users_added"]: + group.users.add(user) group.save() return resp diff --git a/core/views/page.py b/core/views/page.py index 01fd59f6..e33e84ba 100644 --- a/core/views/page.py +++ b/core/views/page.py @@ -23,7 +23,8 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from core.models import LockError, Page, PageRev 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): diff --git a/core/views/widgets/markdown.py b/core/views/widgets/markdown.py new file mode 100644 index 00000000..bd442b20 --- /dev/null +++ b/core/views/widgets/markdown.py @@ -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 diff --git a/core/views/widgets/select.py b/core/views/widgets/select.py new file mode 100644 index 00000000..a7f600be --- /dev/null +++ b/core/views/widgets/select.py @@ -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]) diff --git a/counter/api.py b/counter/api.py index 834852d4..f3f0f101 100644 --- a/counter/api.py +++ b/counter/api.py @@ -12,11 +12,23 @@ # 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 counter.models import Counter -from counter.schemas import CounterSchema +from annotated_types import MinLen +from django.db.models import Q +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") @@ -37,3 +49,30 @@ class CounterController(ControllerBase): for c in counters: self.check_object_permissions(c) 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() + ) diff --git a/counter/forms.py b/counter/forms.py index c43de059..0945bdb7 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,10 +1,15 @@ -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField from django import forms from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget +from club.widgets.select import AutoCompleteSelectClub from core.views.forms import NFCTextInput, SelectDate, SelectDateTime +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectMultipleUser, + AutoCompleteSelectUser, +) from counter.models import ( BillingInfo, Counter, @@ -14,6 +19,11 @@ from counter.models import ( Refilling, StudentCard, ) +from counter.widgets.select import ( + AutoCompleteSelectMultipleCounter, + AutoCompleteSelectMultipleProduct, + AutoCompleteSelectProduct, +) class BillingInfoForm(forms.ModelForm): @@ -68,8 +78,11 @@ class GetUserForm(forms.Form): required=False, widget=NFCTextInput, ) - id = AutoCompleteSelectField( - "users", required=False, label=_("Select user"), help_text=None + id = forms.CharField( + label=_("Select user"), + help_text=None, + widget=AutoCompleteSelectUser, + required=False, ) def as_p(self): @@ -122,8 +135,10 @@ class CounterEditForm(forms.ModelForm): model = Counter fields = ["sellers", "products"] - sellers = make_ajax_field(Counter, "sellers", "users", help_text="") - products = make_ajax_field(Counter, "products", "products", help_text="") + widgets = { + "sellers": AutoCompleteSelectMultipleUser, + "products": AutoCompleteSelectMultipleProduct, + } class ProductEditForm(forms.ModelForm): @@ -145,44 +160,37 @@ class ProductEditForm(forms.ModelForm): "tray", "archived", ] + widgets = { + "parent_product": AutoCompleteSelectMultipleProduct, + "product_type": AutoCompleteSelect, + "buying_groups": AutoCompleteSelectMultipleGroup, + "club": AutoCompleteSelectClub, + } - parent_product = AutoCompleteSelectField( - "products", show_help_text=False, label=_("Parent product"), required=False - ) - 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="", + counters = forms.ModelMultipleChoiceField( + help_text=None, label=_("Counters"), required=False, + widget=AutoCompleteSelectMultipleCounter, + queryset=Counter.objects.all(), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.id: - self.fields["counters"].initial = [ - str(c.id) for c in self.instance.counters.all() - ] + self.fields["counters"].initial = self.instance.counters.all() def save(self, *args, **kwargs): ret = super().save(*args, **kwargs) if self.fields["counters"].initial: - for cid in self.fields["counters"].initial: - c = Counter.objects.filter(id=int(cid)).first() - c.products.remove(self.instance) - c.save() - for cid in self.cleaned_data["counters"]: - c = Counter.objects.filter(id=int(cid)).first() - c.products.add(self.instance) - c.save() + # Remove the product from all counter it was added to + # It will then only be added to selected counters + for counter in self.fields["counters"].initial: + counter.products.remove(self.instance) + counter.save() + for counter in self.cleaned_data["counters"]: + counter.products.add(self.instance) + counter.save() return ret @@ -199,8 +207,7 @@ class EticketForm(forms.ModelForm): class Meta: model = Eticket fields = ["product", "banner", "event_title", "event_date"] - widgets = {"event_date": SelectDate} - - product = AutoCompleteSelectField( - "products", show_help_text=False, label=_("Product"), required=True - ) + widgets = { + "product": AutoCompleteSelectProduct, + "event_date": SelectDate, + } diff --git a/counter/schemas.py b/counter/schemas.py index afe2455d..ec1a842d 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -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 counter.models import Counter +from counter.models import Counter, Product class CounterSchema(ModelSchema): @@ -11,3 +14,19 @@ class CounterSchema(ModelSchema): class Meta: model = Counter 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"] diff --git a/counter/static/webpack/counter/components/ajax-select-index.ts b/counter/static/webpack/counter/components/ajax-select-index.ts new file mode 100644 index 00000000..147e4733 --- /dev/null +++ b/counter/static/webpack/counter/components/ajax-select-index.ts @@ -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 { + const resp = await productSearchProducts({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.code)} - ${sanitize(item.name)} +
`; + } + + protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { + return `${sanitize(item.code)} - ${sanitize(item.name)}`; + } +} + +@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 { + const resp = await counterSearchCounter({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: CounterSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: CounterSchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} diff --git a/counter/widgets/select.py b/counter/widgets/select.py new file mode 100644 index 00000000..53c0bc9f --- /dev/null +++ b/counter/widgets/select.py @@ -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 diff --git a/election/views.py b/election/views.py index 65aaf363..422205fd 100644 --- a/election/views.py +++ b/election/views.py @@ -1,7 +1,5 @@ from typing import TYPE_CHECKING -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.core.exceptions import PermissionDenied 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 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 if TYPE_CHECKING: @@ -51,11 +55,15 @@ class CandidateForm(forms.ModelForm): class Meta: model = Candidature fields = ["user", "role", "program", "election_list"] - widgets = {"program": MarkdownInput} - - user = AutoCompleteSelectField( - "users", label=_("User to candidate"), help_text=None, required=True - ) + labels = { + "user": _("User to candidate"), + } + widgets = { + "program": MarkdownInput, + "user": AutoCompleteSelectUser, + "role": AutoCompleteSelect, + "election_list": AutoCompleteSelect, + } def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -97,6 +105,7 @@ class RoleForm(forms.ModelForm): class Meta: model = Role fields = ["title", "election", "description", "max_choice"] + widgets = {"election": AutoCompleteSelect} def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -120,6 +129,7 @@ class ElectionListForm(forms.ModelForm): class Meta: model = ElectionList fields = ("title", "election") + widgets = {"election": AutoCompleteSelect} def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -146,23 +156,12 @@ class ElectionForm(forms.ModelForm): "vote_groups", "candidature_groups", ] - - edit_groups = make_ajax_field( - Election, "edit_groups", "groups", help_text="", label=_("edit groups") - ) - view_groups = make_ajax_field( - 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"), - ) + widgets = { + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + "vote_groups": AutoCompleteSelectMultipleGroup, + "candidature_groups": AutoCompleteSelectMultipleGroup, + } start_date = forms.DateTimeField( label=_("Start date"), widget=SelectDateTime, required=True @@ -328,6 +327,7 @@ class CandidatureCreateView(CanCreateMixin, CreateView): """Verify that the selected user is in candidate group.""" obj = form.instance 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 ( obj.user == self.request.user or self.can_edit ): diff --git a/forum/views.py b/forum/views.py index 052eb068..074f496d 100644 --- a/forum/views.py +++ b/forum/views.py @@ -25,7 +25,6 @@ import logging import math from functools import partial -from ajax_select import make_ajax_field from django import forms from django.conf import settings 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 honeypot.decorators import check_honeypot +from club.widgets.select import AutoCompleteSelectClub from core.views import ( CanCreateMixin, CanEditMixin, @@ -50,7 +50,11 @@ from core.views import ( CanViewMixin, 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 @@ -165,10 +169,15 @@ class ForumForm(forms.ModelForm): "edit_groups", "view_groups", ] + widgets = { + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + "owner_club": AutoCompleteSelectClub, + } - edit_groups = make_ajax_field(Forum, "edit_groups", "groups", help_text="") - view_groups = make_ajax_field(Forum, "view_groups", "groups", help_text="") - parent = ForumNameField(Forum.objects.all()) + parent = ForumNameField( + Forum.objects.all(), widget=AutoCompleteSelect, required=False + ) class ForumCreateView(CanCreateMixin, CreateView): diff --git a/pedagogy/forms.py b/pedagogy/forms.py index 56a3dce7..9a182f92 100644 --- a/pedagogy/forms.py +++ b/pedagogy/forms.py @@ -25,7 +25,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ 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 diff --git a/poetry.lock b/poetry.lock index c8c6f856..b622f423 100644 --- a/poetry.lock +++ b/poetry.lock @@ -520,16 +520,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] 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]] name = "django-countries" version = "7.6.1" @@ -2691,4 +2681,4 @@ filelock = ">=3.4" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0" +content-hash = "e64ed169395d2c32672a2f2ad6a40d0910e4a51941b564fbdc505db6332084d2" diff --git a/pyproject.toml b/pyproject.toml index 7dc2e8dd..40086159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ django-jinja = "^2.11" cryptography = "^43.0.0" django-phonenumber-field = "^8.0.0" phonenumbers = "^8.13" -django-ajax-selects = "^2.2.1" reportlab = "^4.2" django-haystack = "^3.2.1" xapian-haystack = "^3.0.1" diff --git a/rootplace/views.py b/rootplace/views.py index 4aefb8c3..4bd3c882 100644 --- a/rootplace/views.py +++ b/rootplace/views.py @@ -23,7 +23,6 @@ # import logging -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.core.exceptions import PermissionDenied 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.views import CanEditPropMixin +from core.views.widgets.select import AutoCompleteSelectUser from counter.models import Customer from forum.models import ForumMessageMeta @@ -156,17 +156,29 @@ def delete_all_forum_user_messages( class MergeForm(forms.Form): - user1 = AutoCompleteSelectField( - "users", label=_("User that will be kept"), help_text=None, required=True + user1 = forms.ModelChoiceField( + label=_("User that will be kept"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) - user2 = AutoCompleteSelectField( - "users", label=_("User that will be deleted"), help_text=None, required=True + user2 = forms.ModelChoiceField( + label=_("User that will be deleted"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) class SelectUserForm(forms.Form): - user = AutoCompleteSelectField( - "users", label=_("User to be selected"), help_text=None, required=True + user = forms.ModelChoiceField( + label=_("User to be selected"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) diff --git a/sas/api.py b/sas/api.py index ca4c10c6..6a25607a 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,3 +1,6 @@ +from typing import Annotated + +from annotated_types import MinLen from django.conf import settings from django.db.models import F from django.urls import reverse @@ -9,10 +12,11 @@ from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema 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 sas.models import PeoplePictureRelation, Picture +from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( + AlbumSchema, IdentifiedUserSchema, ModerationRequestSchema, PictureFilterSchema, @@ -22,6 +26,18 @@ from sas.schemas import ( 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") class PicturesController(ControllerBase): @route.get( diff --git a/sas/forms.py b/sas/forms.py index 6569e92a..926fe6ca 100644 --- a/sas/forms.py +++ b/sas/forms.py @@ -1,14 +1,14 @@ from typing import Any -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectMultipleField from django import forms from django.utils.translation import gettext_lazy as _ from core.models import User from core.views import MultipleImageField 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): @@ -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 Meta: model = Picture fields = ["name", "parent"] - - parent = make_ajax_field(Picture, "parent", "files", help_text="") + widgets = {"parent": AutoCompleteSelectAlbum} class AlbumEditForm(forms.ModelForm): class Meta: model = Album 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")) 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) diff --git a/sas/schemas.py b/sas/schemas.py index 6647f7d1..5e049858 100644 --- a/sas/schemas.py +++ b/sas/schemas.py @@ -1,11 +1,24 @@ from datetime import datetime +from pathlib import Path from django.urls import reverse from ninja import FilterSchema, ModelSchema, Schema from pydantic import Field, NonNegativeInt 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): diff --git a/sas/static/webpack/sas/components/ajax-select-index.ts b/sas/static/webpack/sas/components/ajax-select-index.ts new file mode 100644 index 00000000..5b811f52 --- /dev/null +++ b/sas/static/webpack/sas/components/ajax-select-index.ts @@ -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 { + const resp = await albumSearchAlbum({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.path)} +
`; + } + + protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) { + return `${sanitize(item.path)}`; + } +} diff --git a/sas/static/webpack/sas/viewer-index.ts b/sas/static/webpack/sas/viewer-index.ts index b084810c..faa9505a 100644 --- a/sas/static/webpack/sas/viewer-index.ts +++ b/sas/static/webpack/sas/viewer-index.ts @@ -177,7 +177,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { } as PicturesFetchPicturesData) ).map(PictureWithIdentifications.fromPicture); this.selector = this.$refs.search; - this.selector.filter = (users: UserProfileSchema[]) => { + this.selector.setFilter((users: UserProfileSchema[]) => { const resp: UserProfileSchema[] = []; const ids = [ ...(this.currentPicture.identifications || []).map( @@ -190,7 +190,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { } } return resp; - }; + }); this.currentPicture = this.pictures.find( (i: PictureSchema) => i.id === config.firstPictureId, ); diff --git a/sas/templates/sas/picture.jinja b/sas/templates/sas/picture.jinja index 43651383..c14fe72b 100644 --- a/sas/templates/sas/picture.jinja +++ b/sas/templates/sas/picture.jinja @@ -1,12 +1,13 @@ {% extends "core/base.jinja" %} {%- block additional_css -%} - - + + + {%- endblock -%} {%- block additional_js -%} - + {%- endblock -%} @@ -157,12 +158,12 @@
{% trans %}People{% endtrans %}
{% if user.was_subscribed %}
- + delay="300" + placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}" + >
{% endif %} diff --git a/sas/widgets/select.py b/sas/widgets/select.py new file mode 100644 index 00000000..be5c3ebb --- /dev/null +++ b/sas/widgets/select.py @@ -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 diff --git a/sith/settings.py b/sith/settings.py index eb2fdb2d..b21ef444 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -81,7 +81,6 @@ INSTALLED_APPS = ( "honeypot", "django_jinja", "ninja_extra", - "ajax_select", "haystack", "captcha", "core", diff --git a/sith/urls.py b/sith/urls.py index 1979d06c..95f22662 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -13,7 +13,6 @@ # # -from ajax_select import urls as ajax_select_urls from django.conf import settings from django.conf.urls.static import static from django.contrib import admin @@ -59,7 +58,6 @@ urlpatterns = [ path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")), path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")), path("admin/", admin.site.urls), - path("ajax_select/", include(ajax_select_urls)), path("i18n/", include("django.conf.urls.i18n")), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("captcha/", include("captcha.urls")), diff --git a/subscription/views.py b/subscription/views.py index a508513f..4abc8e83 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -15,7 +15,6 @@ import random -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.conf import settings 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.views.forms import SelectDate, SelectDateTime +from core.views.widgets.select import AutoCompleteSelectUser from subscription.models import Subscription @@ -43,11 +43,11 @@ class SubscriptionForm(forms.ModelForm): class Meta: model = Subscription fields = ["member", "subscription_type", "payment_method", "location"] - - member = AutoCompleteSelectField("users", required=False, help_text=None) + widgets = {"member": AutoCompleteSelectUser} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields["member"].required = False self.fields |= forms.fields_for_model( User, fields=["first_name", "last_name", "email", "date_of_birth"], diff --git a/trombi/views.py b/trombi/views.py index 8dd889cc..c5a1d205 100644 --- a/trombi/views.py +++ b/trombi/views.py @@ -24,7 +24,6 @@ from datetime import date -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin @@ -49,6 +48,7 @@ from core.views import ( TabedViewMixin, ) from core.views.forms import SelectDate +from core.views.widgets.select import AutoCompleteSelectUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser @@ -147,8 +147,12 @@ class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView): class AddUserForm(forms.Form): - user = AutoCompleteSelectField( - "users", required=True, label=_("Select user"), help_text=None + user = forms.ModelChoiceField( + label=_("Select user"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) diff --git a/webpack.config.js b/webpack.config.js index ca2b7046..5ff49e1f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,7 +10,7 @@ module.exports = { .sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)") .reduce((obj, el) => { // We include the path inside the webpack folder in the name - const relativePath = []; + let relativePath = []; const fullPath = path.parse(el); for (const dir of fullPath.dir.split("/").reverse()) { if (dir === "webpack") { @@ -18,6 +18,8 @@ module.exports = { } relativePath.push(dir); } + // We collected folders in reverse order, we put them back in the original order + relativePath = relativePath.reverse(); relativePath.push(fullPath.name); obj[relativePath.join("/")] = `./${el}`; return obj;