From b06a06f50cac1f237dfadedd831879559764bd3e Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 7 Dec 2024 13:38:31 +0100 Subject: [PATCH 1/8] feat: add restore on backspace plugin for tom select --- core/static/bundled/core/components/ajax-select-base.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/static/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts index 674b7b73..525de097 100644 --- a/core/static/bundled/core/components/ajax-select-base.ts +++ b/core/static/bundled/core/components/ajax-select-base.ts @@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") { remove_button: { title: gettext("Remove"), }, + // biome-ignore lint/style/useNamingConvention: this is required by the api + restore_on_backspace: {} }, persist: false, maxItems: this.node.multiple ? this.max : 1, From e680124d7b9ea9ae1058bbc32bfaea0155ec0379 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 13 Dec 2024 23:41:24 +0100 Subject: [PATCH 2/8] fix makemessages command in docs --- docs/howto/translation.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/howto/translation.md b/docs/howto/translation.md index 6ae299ed..02f9f87b 100644 --- a/docs/howto/translation.md +++ b/docs/howto/translation.md @@ -37,8 +37,11 @@ Il faut d'abord générer un fichier de traductions, l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serveur. ```bash -./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend -./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend +# Pour le backend +./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules + +# Pour le frontend +./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules --ignore=staticfiles/generated ``` ## Éditer le fichier django.po From 6c8a6008d591561367dde8cf646114de974adac4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 13 Dec 2024 23:58:25 +0100 Subject: [PATCH 3/8] api route to search products with detailed infos. --- .../core/components/ajax-select-base.ts | 2 +- counter/api.py | 50 ++++++++++++----- counter/schemas.py | 48 +++++++++++++++- .../counter/components/ajax-select-index.ts | 6 +- counter/tests/test_product.py | 55 +++++++++++++++++++ counter/widgets/select.py | 6 +- 6 files changed, 144 insertions(+), 23 deletions(-) diff --git a/core/static/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts index 525de097..06c3508e 100644 --- a/core/static/bundled/core/components/ajax-select-base.ts +++ b/core/static/bundled/core/components/ajax-select-base.ts @@ -68,7 +68,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") { title: gettext("Remove"), }, // biome-ignore lint/style/useNamingConvention: this is required by the api - restore_on_backspace: {} + restore_on_backspace: {}, }, persist: false, maxItems: this.node.multiple ? this.max : 1, diff --git a/counter/api.py b/counter/api.py index f3f0f101..7c181aa0 100644 --- a/counter/api.py +++ b/counter/api.py @@ -12,21 +12,21 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from typing import Annotated - -from annotated_types import MinLen -from django.db.models import Q +from django.conf import settings +from django.db.models import F 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 core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from counter.models import Counter, Product from counter.schemas import ( CounterFilterSchema, CounterSchema, + ProductFilterSchema, ProductSchema, + SimpleProductSchema, SimplifiedCounterSchema, ) @@ -64,15 +64,39 @@ class CounterController(ControllerBase): class ProductController(ControllerBase): @route.get( "/search", - response=PaginatedResponseSchema[ProductSchema], + response=PaginatedResponseSchema[SimpleProductSchema], 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() + def search_products(self, filters: Query[ProductFilterSchema]): + return filters.filter( + Product.objects.order_by( + F("product_type__priority").desc(nulls_last=True), + "product_type", + "name", + ).values() + ) + + @route.get( + "/search/detailed", + response=PaginatedResponseSchema[ProductSchema], + permissions=[ + IsRoot + | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) + | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) + ], + url_name="search_products_detailed", + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_products_detailed(self, filters: Query[ProductFilterSchema]): + """Get the detailed information about the products.""" + return filters.filter( + Product.objects.select_related("club") + .prefetch_related("buying_groups") + .select_related("product_type") + .order_by( + F("product_type__priority").desc(nulls_last=True), + "product_type", + "name", + ) ) diff --git a/counter/schemas.py b/counter/schemas.py index ec1a842d..7ecb346b 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,10 +1,12 @@ from typing import Annotated from annotated_types import MinLen +from django.urls import reverse from ninja import Field, FilterSchema, ModelSchema -from core.schemas import SimpleUserSchema -from counter.models import Counter, Product +from club.schemas import ClubSchema +from core.schemas import GroupSchema, SimpleUserSchema +from counter.models import Counter, Product, ProductType class CounterSchema(ModelSchema): @@ -26,7 +28,47 @@ class SimplifiedCounterSchema(ModelSchema): fields = ["id", "name"] -class ProductSchema(ModelSchema): +class ProductTypeSchema(ModelSchema): + class Meta: + model = ProductType + fields = ["id", "name"] + + +class SimpleProductSchema(ModelSchema): class Meta: model = Product fields = ["id", "name", "code"] + + +class ProductSchema(ModelSchema): + class Meta: + model = Product + fields = [ + "id", + "name", + "code", + "description", + "purchase_price", + "selling_price", + "icon", + "limit_age", + "archived", + ] + + buying_groups: list[GroupSchema] + club: ClubSchema + product_type: ProductTypeSchema | None + url: str + + @staticmethod + def resolve_url(obj: Product) -> str: + return reverse("counter:product_edit", kwargs={"product_id": obj.id}) + + +class ProductFilterSchema(FilterSchema): + search: Annotated[str, MinLen(1)] | None = Field( + None, q=["name__icontains", "code__icontains"] + ) + is_archived: bool | None = Field(None, q="archived") + buying_groups: set[int] | None = Field(None, q="buying_groups__in") + product_type: set[int] | None = Field(None, q="product_type__in") diff --git a/counter/static/bundled/counter/components/ajax-select-index.ts b/counter/static/bundled/counter/components/ajax-select-index.ts index 147e4733..a2d61a48 100644 --- a/counter/static/bundled/counter/components/ajax-select-index.ts +++ b/counter/static/bundled/counter/components/ajax-select-index.ts @@ -4,7 +4,7 @@ import type { TomOption } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/dist/types/utils"; import { type CounterSchema, - type ProductSchema, + type SimpleProductSchema, counterSearchCounter, productSearchProducts, } from "#openapi"; @@ -23,13 +23,13 @@ export class ProductAjaxSelect extends AjaxSelect { return []; } - protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { + protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { return `
${sanitize(item.code)} - ${sanitize(item.name)}
`; } - protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { + protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { return `${sanitize(item.code)} - ${sanitize(item.name)}`; } } diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index d18c6f11..a5eb39c4 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -1,11 +1,19 @@ from io import BytesIO +from typing import Callable from uuid import uuid4 import pytest +from django.conf import settings +from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse from model_bakery import baker from PIL import Image +from pytest_django.asserts import assertNumQueries +from core.baker_recipes import board_user, subscriber_user +from core.models import RealGroup, User from counter.models import Product, ProductType @@ -31,3 +39,50 @@ def test_resize_product_icon(model): assert product.icon.height == 70 assert product.icon.name == f"products/{name}.webp" assert Image.open(product.icon).format == "WEBP" + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "status_code"), + [ + (lambda: baker.make(User, is_superuser=True), 200), + (board_user.make, 403), + (subscriber_user.make, 403), + ( + lambda: baker.make( + User, + groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + ), + 200, + ), + ( + lambda: baker.make( + User, + groups=[ + RealGroup.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) + ], + ), + 200, + ), + ], +) +def test_fetch_product_access( + client: Client, user_factory: Callable[[], User], status_code: int +): + """Test that only authorized users can use the `GET /product` route.""" + client.force_login(user_factory()) + assert ( + client.get(reverse("api:search_products_detailed")).status_code == status_code + ) + + +@pytest.mark.django_db +def test_fetch_product_nb_queries(client: Client): + client.force_login(baker.make(User, is_superuser=True)) + cache.clear() + with assertNumQueries(5): + # - 2 for authentication + # - 1 for pagination + # - 1 for the actual request + # - 1 to prefetch the related buying_groups + client.get(reverse("api:search_products_detailed")) diff --git a/counter/widgets/select.py b/counter/widgets/select.py index 68b0bfc1..78c92862 100644 --- a/counter/widgets/select.py +++ b/counter/widgets/select.py @@ -2,7 +2,7 @@ from pydantic import TypeAdapter from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from counter.models import Counter, Product -from counter.schemas import ProductSchema, SimplifiedCounterSchema +from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema _js = ["bundled/counter/components/ajax-select-index.ts"] @@ -24,12 +24,12 @@ class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple): class AutoCompleteSelectProduct(AutoCompleteSelect): component_name = "product-ajax-select" model = Product - adapter = TypeAdapter(list[ProductSchema]) + adapter = TypeAdapter(list[SimpleProductSchema]) js = _js class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple): component_name = "product-ajax-select" model = Product - adapter = TypeAdapter(list[ProductSchema]) + adapter = TypeAdapter(list[SimpleProductSchema]) js = _js From 483670e79863afa6f616d2a0643619c687a6d8fc Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 15 Dec 2024 18:55:09 +0100 Subject: [PATCH 4/8] Make `ProductType` an `OrderedModel` --- counter/admin.py | 2 +- counter/api.py | 4 +- ...0028_alter_producttype_comment_and_more.py | 62 +++++++ counter/models.py | 17 +- counter/views/admin.py | 6 +- eboutic/models.py | 2 +- eboutic/templates/eboutic/eboutic_main.jinja | 2 +- locale/fr/LC_MESSAGES/django.po | 172 +++++++++--------- 8 files changed, 167 insertions(+), 100 deletions(-) create mode 100644 counter/migrations/0028_alter_producttype_comment_and_more.py diff --git a/counter/admin.py b/counter/admin.py index b3e6a91a..5dc795f2 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin): @admin.register(ProductType) class ProductTypeAdmin(admin.ModelAdmin): - list_display = ("name", "priority") + list_display = ("name", "order") @admin.register(CashRegisterSummary) diff --git a/counter/api.py b/counter/api.py index 7c181aa0..8c37d0ac 100644 --- a/counter/api.py +++ b/counter/api.py @@ -71,7 +71,7 @@ class ProductController(ControllerBase): def search_products(self, filters: Query[ProductFilterSchema]): return filters.filter( Product.objects.order_by( - F("product_type__priority").desc(nulls_last=True), + F("product_type__order").asc(nulls_last=True), "product_type", "name", ).values() @@ -95,7 +95,7 @@ class ProductController(ControllerBase): .prefetch_related("buying_groups") .select_related("product_type") .order_by( - F("product_type__priority").desc(nulls_last=True), + F("product_type__order").asc(nulls_last=True), "product_type", "name", ) diff --git a/counter/migrations/0028_alter_producttype_comment_and_more.py b/counter/migrations/0028_alter_producttype_comment_and_more.py new file mode 100644 index 00000000..f7fabb83 --- /dev/null +++ b/counter/migrations/0028_alter_producttype_comment_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.17 on 2024-12-15 17:53 + +from django.db import migrations, models +from django.db.migrations.state import StateApps + + +def move_priority_to_order(apps: StateApps, schema_editor): + """Migrate the previous homemade `priority` to `OrderedModel.order`. + + `priority` was a system were click managers set themselves the priority + of a ProductType. + The higher the priority, the higher it was to be displayed in the eboutic. + Multiple product types could share the same priority, in which + case they were ordered by alphabetic order. + + The new field is unique per object, and works in the other way : + the nearer from 0, the higher it should appear. + """ + ProductType = apps.get_model("counter", "ProductType") + product_types = list(ProductType.objects.order_by("-priority", "name")) + for order, product_type in enumerate(product_types): + product_type.order = order + ProductType.objects.bulk_update(product_types, ["order"]) + + +class Migration(migrations.Migration): + dependencies = [("counter", "0027_alter_refilling_payment_method")] + + operations = [ + migrations.AlterField( + model_name="producttype", + name="comment", + field=models.TextField( + default="", + help_text="A text that will be shown on the eboutic.", + verbose_name="comment", + ), + ), + migrations.AlterField( + model_name="producttype", + name="description", + field=models.TextField(default="", verbose_name="description"), + ), + migrations.AlterModelOptions( + name="producttype", + options={"ordering": ["order"], "verbose_name": "product type"}, + ), + migrations.AddField( + model_name="producttype", + name="order", + field=models.PositiveIntegerField( + db_index=True, default=0, editable=False, verbose_name="order" + ), + preserve_default=False, + ), + migrations.RunPython( + move_priority_to_order, + reverse_code=migrations.RunPython.noop, + elidable=True, + ), + migrations.RemoveField(model_name="producttype", name="priority"), + ] diff --git a/counter/models.py b/counter/models.py index 087baffc..b55207fb 100644 --- a/counter/models.py +++ b/counter/models.py @@ -35,6 +35,7 @@ from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField +from ordered_model.models import OrderedModel from phonenumber_field.modelfields import PhoneNumberField from accounting.models import CurrencyField @@ -289,26 +290,26 @@ class AccountDump(models.Model): ) -class ProductType(models.Model): +class ProductType(OrderedModel): """A product type. Useful only for categorizing. """ name = models.CharField(_("name"), max_length=30) - description = models.TextField(_("description"), null=True, blank=True) - comment = models.TextField(_("comment"), null=True, blank=True) + description = models.TextField(_("description"), default="") + comment = models.TextField( + _("comment"), + default="", + help_text=_("A text that will be shown on the eboutic."), + ) icon = ResizedImageField( height=70, force_format="WEBP", upload_to="products", null=True, blank=True ) - # priority holds no real backend logic but helps to handle the order in which - # the items are to be shown to the user - priority = models.PositiveIntegerField(default=0) - class Meta: verbose_name = _("product type") - ordering = ["-priority", "name"] + ordering = ["order"] def __str__(self): return self.name diff --git a/counter/views/admin.py b/counter/views/admin.py index fbf466b3..c1f5c63b 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -109,7 +109,7 @@ class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView """A create view for the admins.""" model = ProductType - fields = ["name", "description", "comment", "icon", "priority"] + fields = ["name", "description", "comment", "icon"] template_name = "core/create.jinja" current_tab = "products" @@ -119,7 +119,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): model = ProductType template_name = "core/edit.jinja" - fields = ["name", "description", "comment", "icon", "priority"] + fields = ["name", "description", "comment", "icon"] pk_url_kwarg = "type_id" current_tab = "products" @@ -129,7 +129,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): queryset = Product.objects.values("id", "name", "code", "product_type__name") template_name = "counter/product_list.jinja" ordering = [ - F("product_type__priority").desc(nulls_last=True), + F("product_type__order").asc(nulls_last=True), "product_type", "name", ] diff --git a/eboutic/models.py b/eboutic/models.py index 7ec9deef..7f7282b1 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -36,7 +36,7 @@ def get_eboutic_products(user: User) -> list[Product]: .products.filter(product_type__isnull=False) .filter(archived=False) .filter(limit_age__lte=user.age) - .annotate(priority=F("product_type__priority")) + .annotate(order=F("product_type__order")) .annotate(category=F("product_type__name")) .annotate(category_comment=F("product_type__comment")) .prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to` diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index bf5d7556..b71eb434 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -88,7 +88,7 @@ {% endif %} - {% for priority_groups in products|groupby('priority')|reverse %} + {% for priority_groups in products|groupby('order') %} {% for category, items in priority_groups.list|groupby('category') %} {% if items|count > 0 %}
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1f74ddaa..20ddf28a 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-17 10:53+0100\n" +"POT-Creation-Date: 2024-12-17 13:09+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -18,8 +18,8 @@ msgstr "" #: accounting/models.py:62 accounting/models.py:101 accounting/models.py:132 #: accounting/models.py:190 club/models.py:55 com/models.py:274 -#: com/models.py:293 counter/models.py:298 counter/models.py:329 -#: counter/models.py:480 forum/models.py:60 launderette/models.py:29 +#: com/models.py:293 counter/models.py:299 counter/models.py:330 +#: counter/models.py:481 forum/models.py:60 launderette/models.py:29 #: launderette/models.py:80 launderette/models.py:116 msgid "name" msgstr "nom" @@ -65,8 +65,8 @@ msgid "account number" msgstr "numéro de compte" #: accounting/models.py:107 accounting/models.py:136 club/models.py:345 -#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:358 -#: counter/models.py:482 trombi/models.py:209 +#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:359 +#: counter/models.py:483 trombi/models.py:209 msgid "club" msgstr "club" @@ -87,12 +87,12 @@ msgstr "Compte club" msgid "%(club_account)s on %(bank_account)s" msgstr "%(club_account)s sur %(bank_account)s" -#: accounting/models.py:188 club/models.py:351 counter/models.py:965 +#: accounting/models.py:188 club/models.py:351 counter/models.py:966 #: election/models.py:16 launderette/models.py:165 msgid "start date" msgstr "date de début" -#: accounting/models.py:189 club/models.py:352 counter/models.py:966 +#: accounting/models.py:189 club/models.py:352 counter/models.py:967 #: election/models.py:17 msgid "end date" msgstr "date de fin" @@ -105,8 +105,8 @@ msgstr "est fermé" msgid "club account" msgstr "compte club" -#: accounting/models.py:199 accounting/models.py:255 counter/models.py:92 -#: counter/models.py:683 +#: accounting/models.py:199 accounting/models.py:255 counter/models.py:93 +#: counter/models.py:684 msgid "amount" msgstr "montant" @@ -128,18 +128,18 @@ msgstr "classeur" #: accounting/models.py:256 core/models.py:956 core/models.py:1467 #: core/models.py:1512 core/models.py:1541 core/models.py:1565 -#: counter/models.py:693 counter/models.py:797 counter/models.py:1001 +#: counter/models.py:694 counter/models.py:798 counter/models.py:1002 #: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312 #: forum/models.py:413 msgid "date" msgstr "date" -#: accounting/models.py:257 counter/models.py:300 counter/models.py:1002 +#: accounting/models.py:257 counter/models.py:302 counter/models.py:1003 #: pedagogy/models.py:208 msgid "comment" msgstr "commentaire" -#: accounting/models.py:259 counter/models.py:695 counter/models.py:799 +#: accounting/models.py:259 counter/models.py:696 counter/models.py:800 #: subscription/models.py:56 msgid "payment method" msgstr "méthode de paiement" @@ -166,7 +166,7 @@ msgstr "type comptable" #: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460 #: accounting/models.py:492 core/models.py:1540 core/models.py:1566 -#: counter/models.py:763 +#: counter/models.py:764 msgid "label" msgstr "étiquette" @@ -264,7 +264,7 @@ msgstr "" "Vous devez fournir soit un type comptable simplifié ou un type comptable " "standard" -#: accounting/models.py:421 counter/models.py:339 pedagogy/models.py:41 +#: accounting/models.py:421 counter/models.py:340 pedagogy/models.py:41 msgid "code" msgstr "code" @@ -1041,7 +1041,7 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs" msgid "A club with that unix_name already exists" msgstr "Un club avec ce nom UNIX existe déjà." -#: club/models.py:337 counter/models.py:956 counter/models.py:992 +#: club/models.py:337 counter/models.py:957 counter/models.py:993 #: eboutic/models.py:53 eboutic/models.py:189 election/models.py:183 #: launderette/models.py:130 launderette/models.py:184 sas/models.py:273 #: trombi/models.py:205 @@ -1053,8 +1053,8 @@ msgstr "nom d'utilisateur" msgid "role" msgstr "rôle" -#: club/models.py:359 core/models.py:90 counter/models.py:299 -#: counter/models.py:330 election/models.py:13 election/models.py:115 +#: club/models.py:359 core/models.py:90 counter/models.py:300 +#: counter/models.py:331 election/models.py:13 election/models.py:115 #: election/models.py:188 forum/models.py:61 forum/models.py:245 msgid "description" msgstr "description" @@ -2501,7 +2501,7 @@ msgstr "Forum" msgid "Gallery" msgstr "Photos" -#: core/templates/core/base/navbar.jinja:22 counter/models.py:490 +#: core/templates/core/base/navbar.jinja:22 counter/models.py:491 #: counter/templates/counter/counter_list.jinja:11 #: eboutic/templates/eboutic/eboutic_main.jinja:4 #: eboutic/templates/eboutic/eboutic_main.jinja:22 @@ -3607,13 +3607,13 @@ msgstr "Chèque" msgid "Cash" msgstr "Espèces" -#: counter/apps.py:30 counter/models.py:801 sith/settings.py:415 +#: counter/apps.py:30 counter/models.py:802 sith/settings.py:415 #: sith/settings.py:420 msgid "Credit card" msgstr "Carte bancaire" -#: counter/apps.py:36 counter/models.py:506 counter/models.py:962 -#: counter/models.py:998 launderette/models.py:32 +#: counter/apps.py:36 counter/models.py:507 counter/models.py:963 +#: counter/models.py:999 launderette/models.py:32 msgid "counter" msgstr "comptoir" @@ -3637,180 +3637,184 @@ msgstr "Vidange de votre compte AE" msgid "Ecocup regularization" msgstr "Régularization des ecocups" -#: counter/models.py:91 +#: counter/models.py:92 msgid "account id" msgstr "numéro de compte" -#: counter/models.py:93 +#: counter/models.py:94 msgid "recorded product" msgstr "produits consignés" -#: counter/models.py:98 +#: counter/models.py:99 msgid "customer" msgstr "client" -#: counter/models.py:99 +#: counter/models.py:100 msgid "customers" msgstr "clients" -#: counter/models.py:111 counter/views/click.py:68 +#: counter/models.py:112 counter/views/click.py:68 msgid "Not enough money" msgstr "Solde insuffisant" -#: counter/models.py:197 +#: counter/models.py:198 msgid "First name" msgstr "Prénom" -#: counter/models.py:198 +#: counter/models.py:199 msgid "Last name" msgstr "Nom de famille" -#: counter/models.py:199 +#: counter/models.py:200 msgid "Address 1" msgstr "Adresse 1" -#: counter/models.py:200 +#: counter/models.py:201 msgid "Address 2" msgstr "Adresse 2" -#: counter/models.py:201 +#: counter/models.py:202 msgid "Zip code" msgstr "Code postal" -#: counter/models.py:202 +#: counter/models.py:203 msgid "City" msgstr "Ville" -#: counter/models.py:203 +#: counter/models.py:204 msgid "Country" msgstr "Pays" -#: counter/models.py:211 +#: counter/models.py:212 msgid "Phone number" msgstr "Numéro de téléphone" -#: counter/models.py:253 +#: counter/models.py:254 msgid "When the mail warning that the account was about to be dumped was sent." msgstr "Quand le mail d'avertissement de la vidange du compte a été envoyé." -#: counter/models.py:258 +#: counter/models.py:259 msgid "Set this to True if the warning mail received an error" msgstr "Mettre à True si le mail a reçu une erreur" -#: counter/models.py:265 +#: counter/models.py:266 msgid "The operation that emptied the account." msgstr "L'opération qui a vidé le compte." -#: counter/models.py:310 counter/models.py:334 +#: counter/models.py:304 +msgid "A text that will be shown on the eboutic." +msgstr "Un texte qui sera affiché sur l'eboutic." + +#: counter/models.py:311 counter/models.py:335 msgid "product type" msgstr "type du produit" -#: counter/models.py:341 +#: counter/models.py:342 msgid "purchase price" msgstr "prix d'achat" -#: counter/models.py:342 +#: counter/models.py:343 msgid "Initial cost of purchasing the product" msgstr "Coût initial d'achat du produit" -#: counter/models.py:344 +#: counter/models.py:345 msgid "selling price" msgstr "prix de vente" -#: counter/models.py:346 +#: counter/models.py:347 msgid "special selling price" msgstr "prix de vente spécial" -#: counter/models.py:347 +#: counter/models.py:348 msgid "Price for barmen during their permanence" msgstr "Prix pour les barmen durant leur permanence" -#: counter/models.py:355 +#: counter/models.py:356 msgid "icon" msgstr "icône" -#: counter/models.py:360 +#: counter/models.py:361 msgid "limit age" msgstr "âge limite" -#: counter/models.py:361 +#: counter/models.py:362 msgid "tray price" msgstr "prix plateau" -#: counter/models.py:363 +#: counter/models.py:364 msgid "buying groups" msgstr "groupe d'achat" -#: counter/models.py:365 election/models.py:50 +#: counter/models.py:366 election/models.py:50 msgid "archived" msgstr "archivé" -#: counter/models.py:368 counter/models.py:1096 +#: counter/models.py:369 counter/models.py:1097 msgid "product" msgstr "produit" -#: counter/models.py:485 +#: counter/models.py:486 msgid "products" msgstr "produits" -#: counter/models.py:488 +#: counter/models.py:489 msgid "counter type" msgstr "type de comptoir" -#: counter/models.py:490 +#: counter/models.py:491 msgid "Bar" msgstr "Bar" -#: counter/models.py:490 +#: counter/models.py:491 msgid "Office" msgstr "Bureau" -#: counter/models.py:493 +#: counter/models.py:494 msgid "sellers" msgstr "vendeurs" -#: counter/models.py:501 launderette/models.py:178 +#: counter/models.py:502 launderette/models.py:178 msgid "token" msgstr "jeton" -#: counter/models.py:701 +#: counter/models.py:702 msgid "bank" msgstr "banque" -#: counter/models.py:703 counter/models.py:804 +#: counter/models.py:704 counter/models.py:805 msgid "is validated" msgstr "est validé" -#: counter/models.py:708 +#: counter/models.py:709 msgid "refilling" msgstr "rechargement" -#: counter/models.py:781 eboutic/models.py:249 +#: counter/models.py:782 eboutic/models.py:249 msgid "unit price" msgstr "prix unitaire" -#: counter/models.py:782 counter/models.py:1076 eboutic/models.py:250 +#: counter/models.py:783 counter/models.py:1077 eboutic/models.py:250 msgid "quantity" msgstr "quantité" -#: counter/models.py:801 +#: counter/models.py:802 msgid "Sith account" msgstr "Compte utilisateur" -#: counter/models.py:809 +#: counter/models.py:810 msgid "selling" msgstr "vente" -#: counter/models.py:913 +#: counter/models.py:914 msgid "Unknown event" msgstr "Événement inconnu" -#: counter/models.py:914 +#: counter/models.py:915 #, python-format msgid "Eticket bought for the event %(event)s" msgstr "Eticket acheté pour l'événement %(event)s" -#: counter/models.py:916 counter/models.py:929 +#: counter/models.py:917 counter/models.py:930 #, python-format msgid "" "You bought an eticket for the event %(event)s.\n" @@ -3822,67 +3826,67 @@ msgstr "" "Vous pouvez également retrouver tous vos e-tickets sur votre page de compte " "%(url)s." -#: counter/models.py:967 +#: counter/models.py:968 msgid "last activity date" msgstr "dernière activité" -#: counter/models.py:970 +#: counter/models.py:971 msgid "permanency" msgstr "permanence" -#: counter/models.py:1003 +#: counter/models.py:1004 msgid "emptied" msgstr "coffre vidée" -#: counter/models.py:1006 +#: counter/models.py:1007 msgid "cash register summary" msgstr "relevé de caisse" -#: counter/models.py:1072 +#: counter/models.py:1073 msgid "cash summary" msgstr "relevé" -#: counter/models.py:1075 +#: counter/models.py:1076 msgid "value" msgstr "valeur" -#: counter/models.py:1078 +#: counter/models.py:1079 msgid "check" msgstr "chèque" -#: counter/models.py:1080 +#: counter/models.py:1081 msgid "True if this is a bank check, else False" msgstr "Vrai si c'est un chèque, sinon Faux." -#: counter/models.py:1084 +#: counter/models.py:1085 msgid "cash register summary item" msgstr "élément de relevé de caisse" -#: counter/models.py:1100 +#: counter/models.py:1101 msgid "banner" msgstr "bannière" -#: counter/models.py:1102 +#: counter/models.py:1103 msgid "event date" msgstr "date de l'événement" -#: counter/models.py:1104 +#: counter/models.py:1105 msgid "event title" msgstr "titre de l'événement" -#: counter/models.py:1106 +#: counter/models.py:1107 msgid "secret" msgstr "secret" -#: counter/models.py:1145 +#: counter/models.py:1146 msgid "uid" msgstr "uid" -#: counter/models.py:1150 counter/models.py:1155 +#: counter/models.py:1151 counter/models.py:1156 msgid "student card" msgstr "carte étudiante" -#: counter/models.py:1156 +#: counter/models.py:1157 msgid "student cards" msgstr "cartes étudiantes" @@ -4194,15 +4198,15 @@ msgid "There is no products in this website." msgstr "Il n'y a pas de produits dans ce site web." #: counter/templates/counter/producttype_list.jinja:4 -#: counter/templates/counter/producttype_list.jinja:10 +#: counter/templates/counter/producttype_list.jinja:26 msgid "Product type list" msgstr "Liste des types de produit" -#: counter/templates/counter/producttype_list.jinja:8 +#: counter/templates/counter/producttype_list.jinja:16 msgid "New product type" msgstr "Nouveau type de produit" -#: counter/templates/counter/producttype_list.jinja:17 +#: counter/templates/counter/producttype_list.jinja:42 msgid "There is no product types in this website." msgstr "Il n'y a pas de types de produit dans ce site web." From c79c251ba7657adf641a42dcf2a537bc626952a1 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 16 Dec 2024 19:46:34 +0100 Subject: [PATCH 5/8] Add `ProductTypeController` --- counter/api.py | 54 ++++++++++++++++-- counter/schemas.py | 32 ++++++++++- counter/tests/test_product_type.py | 91 ++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 counter/tests/test_product_type.py diff --git a/counter/api.py b/counter/api.py index 8c37d0ac..43fada5a 100644 --- a/counter/api.py +++ b/counter/api.py @@ -14,22 +14,31 @@ # from django.conf import settings from django.db.models import F +from django.shortcuts import get_object_or_404 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, IsInGroup, IsRoot -from counter.models import Counter, Product +from counter.models import Counter, Product, ProductType from counter.schemas import ( CounterFilterSchema, CounterSchema, ProductFilterSchema, ProductSchema, + ProductTypeSchema, + ReorderProductTypeSchema, SimpleProductSchema, SimplifiedCounterSchema, ) +IsCounterAdmin = ( + IsRoot + | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) + | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) +) + @api_controller("/counter") class CounterController(ControllerBase): @@ -80,11 +89,7 @@ class ProductController(ControllerBase): @route.get( "/search/detailed", response=PaginatedResponseSchema[ProductSchema], - permissions=[ - IsRoot - | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) - | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - ], + permissions=[IsCounterAdmin], url_name="search_products_detailed", ) @paginate(PageNumberPaginationExtra, page_size=50) @@ -100,3 +105,40 @@ class ProductController(ControllerBase): "name", ) ) + + +@api_controller("/product-type", permissions=[IsCounterAdmin]) +class ProductTypeController(ControllerBase): + @route.get("", response=list[ProductTypeSchema], url_name="fetch-product-types") + def fetch_all(self): + return ProductType.objects.order_by("order") + + @route.patch("/{type_id}/move") + def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]): + """Change the order of a product type. + + To use this route, give either the id of the product type + this one should be above of, + of the id of the product type this one should be below of. + + Order affects the display order of the product types. + + Examples: + ``` + GET /api/counter/product-type + => [<1: type A>, <2: type B>, <3: type C>] + + PATCH /api/counter/product-type/3/move?below=1 + + GET /api/counter/product-type + => [<1: type A>, <3: type C>, <2: type B>] + ``` + """ + product_type: ProductType = self.get_object_or_exception( + ProductType, pk=type_id + ) + other = get_object_or_404(ProductType, pk=other_id.above or other_id.below) + if other_id.below is not None: + product_type.below(other) + else: + product_type.above(other) diff --git a/counter/schemas.py b/counter/schemas.py index 7ecb346b..1b770a3d 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,8 +1,9 @@ -from typing import Annotated +from typing import Annotated, Self from annotated_types import MinLen from django.urls import reverse -from ninja import Field, FilterSchema, ModelSchema +from ninja import Field, FilterSchema, ModelSchema, Schema +from pydantic import model_validator from club.schemas import ClubSchema from core.schemas import GroupSchema, SimpleUserSchema @@ -29,11 +30,36 @@ class SimplifiedCounterSchema(ModelSchema): class ProductTypeSchema(ModelSchema): + class Meta: + model = ProductType + fields = ["id", "name", "description", "comment", "icon", "order"] + + url: str + + @staticmethod + def resolve_url(obj: ProductType) -> str: + return reverse("counter:producttype_edit", kwargs={"type_id": obj.id}) + + +class SimpleProductTypeSchema(ModelSchema): class Meta: model = ProductType fields = ["id", "name"] +class ReorderProductTypeSchema(Schema): + below: int | None = None + above: int | None = None + + @model_validator(mode="after") + def validate_exclusive(self) -> Self: + if self.below is None and self.above is None: + raise ValueError("Either 'below' or 'above' must be set.") + if self.below is not None and self.above is not None: + raise ValueError("Only one of 'below' or 'above' must be set.") + return self + + class SimpleProductSchema(ModelSchema): class Meta: model = Product @@ -57,7 +83,7 @@ class ProductSchema(ModelSchema): buying_groups: list[GroupSchema] club: ClubSchema - product_type: ProductTypeSchema | None + product_type: SimpleProductTypeSchema | None url: str @staticmethod diff --git a/counter/tests/test_product_type.py b/counter/tests/test_product_type.py new file mode 100644 index 00000000..45ed5797 --- /dev/null +++ b/counter/tests/test_product_type.py @@ -0,0 +1,91 @@ +import pytest +from django.conf import settings +from django.test import Client +from django.urls import reverse +from model_bakery import baker, seq +from ninja_extra.testing import TestClient + +from core.baker_recipes import board_user, subscriber_user +from core.models import RealGroup, User +from counter.api import ProductTypeController +from counter.models import ProductType + + +@pytest.fixture +def product_types(db) -> list[ProductType]: + """All existing product types, ordered by their `order` field""" + # delete product types that have been created in the `populate` command + ProductType.objects.all().delete() + return baker.make(ProductType, _quantity=5, order=seq(0)) + + +@pytest.mark.django_db +def test_fetch_product_types(product_types: list[ProductType]): + """Test that the API returns the right products in the right order""" + client = TestClient(ProductTypeController) + response = client.get("") + assert response.status_code == 200 + assert [i["id"] for i in response.json()] == [t.id for t in product_types] + + +@pytest.mark.django_db +def test_move_below_product_type(product_types: list[ProductType]): + """Test that moving a product below another works""" + client = TestClient(ProductTypeController) + response = client.patch( + f"/{product_types[-1].id}/move", query={"below": product_types[0].id} + ) + assert response.status_code == 200 + new_order = [i["id"] for i in client.get("").json()] + assert new_order == [ + product_types[0].id, + product_types[-1].id, + *[t.id for t in product_types[1:-1]], + ] + + +@pytest.mark.django_db +def test_move_above_product_type(product_types: list[ProductType]): + """Test that moving a product above another works""" + client = TestClient(ProductTypeController) + response = client.patch( + f"/{product_types[1].id}/move", query={"above": product_types[0].id} + ) + assert response.status_code == 200 + new_order = [i["id"] for i in client.get("").json()] + assert new_order == [ + product_types[1].id, + product_types[0].id, + *[t.id for t in product_types[2:]], + ] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "status_code"), + [ + (lambda: baker.make(User, is_superuser=True), 200), + (subscriber_user.make, 403), + (board_user.make, 403), + ( + lambda: baker.make( + User, + groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + ), + 200, + ), + ( + lambda: baker.make( + User, + groups=[ + RealGroup.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) + ], + ), + 200, + ), + ], +) +def test_controller_permissions(client: Client, user_factory, status_code): + client.force_login(user_factory()) + response = client.get(reverse("api:fetch-product-types")) + assert response.status_code == status_code From 47876e3971cfafc42cb7770c23b7f195bc3828e3 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 17 Dec 2024 00:53:47 +0100 Subject: [PATCH 6/8] Make product types dynamically orderable. --- core/static/bundled/alpine-index.js | 2 + core/static/core/forms.scss | 18 ++ core/static/core/style.scss | 11 + .../bundled/counter/product-type-index.ts | 64 ++++++ counter/static/counter/css/product_type.scss | 15 ++ .../templates/counter/producttype_list.jinja | 40 +++- locale/fr/LC_MESSAGES/django.po | 2 +- locale/fr/LC_MESSAGES/djangojs.po | 211 +++++++++--------- package-lock.json | 16 +- package.json | 3 +- 10 files changed, 269 insertions(+), 113 deletions(-) create mode 100644 counter/static/bundled/counter/product-type-index.ts create mode 100644 counter/static/counter/css/product_type.scss diff --git a/core/static/bundled/alpine-index.js b/core/static/bundled/alpine-index.js index d07e0bf2..211600a5 100644 --- a/core/static/bundled/alpine-index.js +++ b/core/static/bundled/alpine-index.js @@ -1,5 +1,7 @@ +import sort from "@alpinejs/sort"; import Alpine from "alpinejs"; +Alpine.plugin(sort); window.Alpine = Alpine; window.addEventListener("DOMContentLoaded", () => { diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 7dab0484..e439bd8d 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -87,3 +87,21 @@ a:not(.button) { color: $primary-color; } } + + +form { + .row { + label { + margin: unset; + } + } + + fieldset { + margin-bottom: 1rem; + } + + .helptext { + margin-top: .25rem; + font-size: 80%; + } +} diff --git a/core/static/core/style.scss b/core/static/core/style.scss index cbe8d326..dd44fda0 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -314,6 +314,17 @@ body { } } + .snackbar { + width: 250px; + margin-left: -125px; + box-sizing: border-box; + position: fixed; + z-index: 1; + left: 50%; + top: 60px; + text-align: center; + } + .tabs { border-radius: 5px; diff --git a/counter/static/bundled/counter/product-type-index.ts b/counter/static/bundled/counter/product-type-index.ts new file mode 100644 index 00000000..4d1bdda4 --- /dev/null +++ b/counter/static/bundled/counter/product-type-index.ts @@ -0,0 +1,64 @@ +import Alpine from "alpinejs"; +import { producttypeReorder } from "#openapi"; + +document.addEventListener("alpine:init", () => { + Alpine.data("productTypesList", () => ({ + loading: false, + alertMessage: { + open: false, + success: true, + content: "", + timeout: null, + }, + + async reorder(itemId: number, newPosition: number) { + // The sort plugin of Alpine doesn't manage dynamic lists with x-sort + // (cf. https://github.com/alpinejs/alpine/discussions/4157). + // There is an open PR that fixes this issue + // (cf. https://github.com/alpinejs/alpine/pull/4361). + // However, it hasn't been merged yet. + // To overcome this, I get the list of DOM elements + // And fetch the `x-sort:item` attribute, which value is + // the id of the object in database. + // Please make this a little bit cleaner when the fix has been merged + // into the main Alpine repo. + this.loading = true; + const productTypes = this.$refs.productTypes + .childNodes as NodeListOf; + const getId = (elem: HTMLLIElement) => + Number.parseInt(elem.getAttribute("x-sort:item")); + const query = + newPosition === 0 + ? { above: getId(productTypes.item(1)) } + : { below: getId(productTypes.item(newPosition - 1)) }; + const response = await producttypeReorder({ + // biome-ignore lint/style/useNamingConvention: api is snake_case + path: { type_id: itemId }, + query: query, + }); + this.openAlertMessage(response.response); + this.loading = false; + }, + + openAlertMessage(response: Response) { + if (response.ok) { + this.alertMessage.success = true; + this.alertMessage.content = gettext("Products types successfully reordered"); + } else { + this.alertMessage.success = false; + this.alertMessage.content = interpolate( + gettext("Product type reorganisation failed with status code : %d"), + [response.status], + ); + } + this.alertMessage.open = true; + if (this.alertMessage.timeout !== null) { + clearTimeout(this.alertMessage.timeout); + } + this.alertMessage.timeout = setTimeout(() => { + this.alertMessage.open = false; + }, 2000); + this.loading = false; + }, + })); +}); diff --git a/counter/static/counter/css/product_type.scss b/counter/static/counter/css/product_type.scss new file mode 100644 index 00000000..16bd43a9 --- /dev/null +++ b/counter/static/counter/css/product_type.scss @@ -0,0 +1,15 @@ +.product-type-list { + li { + list-style: none; + margin-bottom: 10px; + + i { + cursor: grab; + visibility: hidden; + } + } +} + +body:not(.sorting) .product-type-list li:hover i { + visibility: visible; +} \ No newline at end of file diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/producttype_list.jinja index 0c4ff0c5..5d7ddc26 100644 --- a/counter/templates/counter/producttype_list.jinja +++ b/counter/templates/counter/producttype_list.jinja @@ -4,21 +4,49 @@ {% trans %}Product type list{% endtrans %} {% endblock %} +{% block additional_css %} + +{% endblock %} + +{% block additional_js %} + +{% endblock %} + {% block content %}

{% trans %}New product type{% endtrans %}

{% if producttype_list %} -

{% trans %}Product type list{% endtrans %}

-
    - {% for t in producttype_list %} -
  • {{ t }}
  • - {% endfor %} -
+
+

+

{% trans %}Product type list{% endtrans %}

+
    + {%- for t in producttype_list -%} +
  • + + {{ t }} +
  • + {%- endfor -%} +
+
{% else %} {% trans %}There is no product types in this website.{% endtrans %} {% endif %} {% endblock %} +{% block script %} + +{% endblock %} diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 20ddf28a..fdfef6b9 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-17 13:09+0100\n" +"POT-Creation-Date: 2024-12-17 13:10+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index e907e571..c89f7eb1 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-14 10:24+0100\n" +"POT-Creation-Date: 2024-12-17 00:46+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -17,119 +17,128 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: core/static/bundled/core/components/ajax-select-base.ts:68 +msgid "Remove" +msgstr "Retirer" + +#: core/static/bundled/core/components/ajax-select-base.ts:90 +msgid "You need to type %(number)s more characters" +msgstr "Vous devez taper %(number)s caractères de plus" + +#: core/static/bundled/core/components/ajax-select-base.ts:94 +msgid "No results found" +msgstr "Aucun résultat trouvé" + +#: core/static/bundled/core/components/easymde-index.ts:38 +msgid "Heading" +msgstr "Titre" + +#: core/static/bundled/core/components/easymde-index.ts:44 +msgid "Italic" +msgstr "Italique" + +#: core/static/bundled/core/components/easymde-index.ts:50 +msgid "Bold" +msgstr "Gras" + +#: core/static/bundled/core/components/easymde-index.ts:56 +msgid "Strikethrough" +msgstr "Barré" + +#: core/static/bundled/core/components/easymde-index.ts:65 +msgid "Underline" +msgstr "Souligné" + +#: core/static/bundled/core/components/easymde-index.ts:74 +msgid "Superscript" +msgstr "Exposant" + +#: core/static/bundled/core/components/easymde-index.ts:83 +msgid "Subscript" +msgstr "Indice" + +#: core/static/bundled/core/components/easymde-index.ts:89 +msgid "Code" +msgstr "Code" + +#: core/static/bundled/core/components/easymde-index.ts:96 +msgid "Quote" +msgstr "Citation" + +#: core/static/bundled/core/components/easymde-index.ts:102 +msgid "Unordered list" +msgstr "Liste non ordonnée" + +#: core/static/bundled/core/components/easymde-index.ts:108 +msgid "Ordered list" +msgstr "Liste ordonnée" + +#: core/static/bundled/core/components/easymde-index.ts:115 +msgid "Insert link" +msgstr "Insérer lien" + +#: core/static/bundled/core/components/easymde-index.ts:121 +msgid "Insert image" +msgstr "Insérer image" + +#: core/static/bundled/core/components/easymde-index.ts:127 +msgid "Insert table" +msgstr "Insérer tableau" + +#: core/static/bundled/core/components/easymde-index.ts:134 +msgid "Clean block" +msgstr "Nettoyer bloc" + +#: core/static/bundled/core/components/easymde-index.ts:141 +msgid "Toggle preview" +msgstr "Activer la prévisualisation" + +#: core/static/bundled/core/components/easymde-index.ts:147 +msgid "Toggle side by side" +msgstr "Activer la vue côte à côte" + +#: core/static/bundled/core/components/easymde-index.ts:153 +msgid "Toggle fullscreen" +msgstr "Activer le plein écran" + +#: core/static/bundled/core/components/easymde-index.ts:160 +msgid "Markdown guide" +msgstr "Guide markdown" + +#: core/static/bundled/core/components/nfc-input-index.ts:26 +msgid "Unsupported NFC card" +msgstr "Carte NFC non supportée" + +#: core/static/bundled/user/family-graph-index.js:233 +msgid "family_tree.%(extension)s" +msgstr "arbre_genealogique.%(extension)s" + +#: core/static/bundled/user/pictures-index.js:76 +msgid "pictures.%(extension)s" +msgstr "photos.%(extension)s" + #: core/static/user/js/user_edit.js:91 #, javascript-format msgid "captured.%s" msgstr "capture.%s" -#: core/static/webpack/core/components/ajax-select-base.ts:68 -msgid "Remove" -msgstr "Retirer" +#: counter/static/bundled/counter/product-type-index.ts:36 +msgid "Products types successfully reordered" +msgstr "Types de produits réordonnés." -#: core/static/webpack/core/components/ajax-select-base.ts:88 -msgid "You need to type %(number)s more characters" -msgstr "Vous devez taper %(number)s caractères de plus" - -#: core/static/webpack/core/components/ajax-select-base.ts:92 -msgid "No results found" -msgstr "Aucun résultat trouvé" - -#: core/static/webpack/core/components/easymde-index.ts:38 -msgid "Heading" -msgstr "Titre" - -#: core/static/webpack/core/components/easymde-index.ts:44 -msgid "Italic" -msgstr "Italique" - -#: core/static/webpack/core/components/easymde-index.ts:50 -msgid "Bold" -msgstr "Gras" - -#: core/static/webpack/core/components/easymde-index.ts:56 -msgid "Strikethrough" -msgstr "Barré" - -#: core/static/webpack/core/components/easymde-index.ts:65 -msgid "Underline" -msgstr "Souligné" - -#: core/static/webpack/core/components/easymde-index.ts:74 -msgid "Superscript" -msgstr "Exposant" - -#: core/static/webpack/core/components/easymde-index.ts:83 -msgid "Subscript" -msgstr "Indice" - -#: core/static/webpack/core/components/easymde-index.ts:89 -msgid "Code" -msgstr "Code" - -#: core/static/webpack/core/components/easymde-index.ts:96 -msgid "Quote" -msgstr "Citation" - -#: core/static/webpack/core/components/easymde-index.ts:102 -msgid "Unordered list" -msgstr "Liste non ordonnée" - -#: core/static/webpack/core/components/easymde-index.ts:108 -msgid "Ordered list" -msgstr "Liste ordonnée" - -#: core/static/webpack/core/components/easymde-index.ts:115 -msgid "Insert link" -msgstr "Insérer lien" - -#: core/static/webpack/core/components/easymde-index.ts:121 -msgid "Insert image" -msgstr "Insérer image" - -#: core/static/webpack/core/components/easymde-index.ts:127 -msgid "Insert table" -msgstr "Insérer tableau" - -#: core/static/webpack/core/components/easymde-index.ts:134 -msgid "Clean block" -msgstr "Nettoyer bloc" - -#: core/static/webpack/core/components/easymde-index.ts:141 -msgid "Toggle preview" -msgstr "Activer la prévisualisation" - -#: core/static/webpack/core/components/easymde-index.ts:147 -msgid "Toggle side by side" -msgstr "Activer la vue côte à côte" - -#: core/static/webpack/core/components/easymde-index.ts:153 -msgid "Toggle fullscreen" -msgstr "Activer le plein écran" - -#: core/static/webpack/core/components/easymde-index.ts:160 -msgid "Markdown guide" -msgstr "Guide markdown" - -#: core/static/webpack/core/components/nfc-input-index.ts:24 -msgid "Unsupported NFC card" -msgstr "Carte NFC non supportée" - -#: core/static/webpack/user/family-graph-index.js:233 -msgid "family_tree.%(extension)s" -msgstr "arbre_genealogique.%(extension)s" - -#: core/static/webpack/user/pictures-index.js:76 -msgid "pictures.%(extension)s" -msgstr "photos.%(extension)s" +#: counter/static/bundled/counter/product-type-index.ts:40 +#, javascript-format +msgid "Product type reorganisation failed with status code : %d" +msgstr "La réorganisation des types de produit a échoué avec le code : %d" #: eboutic/static/eboutic/js/makecommand.js:56 msgid "Incorrect value" msgstr "Valeur incorrecte" -#: sas/static/webpack/sas/viewer-index.ts:271 +#: sas/static/bundled/sas/viewer-index.ts:271 msgid "Couldn't moderate picture" msgstr "Il n'a pas été possible de modérer l'image" -#: sas/static/webpack/sas/viewer-index.ts:284 +#: sas/static/bundled/sas/viewer-index.ts:284 msgid "Couldn't delete picture" msgstr "Il n'a pas été possible de supprimer l'image" diff --git a/package-lock.json b/package-lock.json index 05418a69..c46ef180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,13 @@ "version": "3", "license": "GPL-3.0-only", "dependencies": { + "@alpinejs/sort": "^3.14.7", "@fortawesome/fontawesome-free": "^6.6.0", "@hey-api/client-fetch": "^0.4.0", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", "3d-force-graph": "^1.73.4", - "alpinejs": "^3.14.1", + "alpinejs": "^3.14.7", "chart.js": "^4.4.4", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", @@ -44,6 +45,12 @@ "vite-plugin-static-copy": "^2.1.0" } }, + "node_modules/@alpinejs/sort": { + "version": "3.14.7", + "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.7.tgz", + "integrity": "sha512-EJzxTBSoKvOxKHAUFeTSgxJR4rJQQPm10b4dB38kGcsxjUtOeNkbBF3xV4nlc0ZyTv7DarTWdppdoR/iP8jfdQ==", + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -3064,9 +3071,10 @@ } }, "node_modules/alpinejs": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.1.tgz", - "integrity": "sha512-ICar8UsnRZAYvv/fCNfNeKMXNoXGUfwHrjx7LqXd08zIP95G2d9bAOuaL97re+1mgt/HojqHsfdOLo/A5LuWgQ==", + "version": "3.14.7", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.7.tgz", + "integrity": "sha512-ScnbydNBcWVnCiVupD3wWUvoMPm8244xkvDNMxVCspgmap9m4QuJ7pjc+77UtByU+1+Ejg0wzYkP4mQaOMcvng==", + "license": "MIT", "dependencies": { "@vue/reactivity": "~3.1.1" } diff --git a/package.json b/package.json index 2ca46967..77572a6f 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,13 @@ "vite-plugin-static-copy": "^2.1.0" }, "dependencies": { + "@alpinejs/sort": "^3.14.7", "@fortawesome/fontawesome-free": "^6.6.0", "@hey-api/client-fetch": "^0.4.0", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", "3d-force-graph": "^1.73.4", - "alpinejs": "^3.14.1", + "alpinejs": "^3.14.7", "chart.js": "^4.4.4", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", From 8d643fc6b449dab32f8b0e6f1e61de149e99ba3d Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 17 Dec 2024 17:23:13 +0100 Subject: [PATCH 7/8] Apply review comments --- counter/api.py | 2 +- .../templates/counter/producttype_list.jinja | 26 ++++++++++++------- counter/tests/test_product_type.py | 2 +- locale/fr/LC_MESSAGES/django.po | 20 +++++++++++--- locale/fr/LC_MESSAGES/djangojs.po | 4 +-- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/counter/api.py b/counter/api.py index 43fada5a..dd7b75f0 100644 --- a/counter/api.py +++ b/counter/api.py @@ -109,7 +109,7 @@ class ProductController(ControllerBase): @api_controller("/product-type", permissions=[IsCounterAdmin]) class ProductTypeController(ControllerBase): - @route.get("", response=list[ProductTypeSchema], url_name="fetch-product-types") + @route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types") def fetch_all(self): return ProductType.objects.order_by("order") diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/producttype_list.jinja index 5d7ddc26..042925df 100644 --- a/counter/templates/counter/producttype_list.jinja +++ b/counter/templates/counter/producttype_list.jinja @@ -13,8 +13,24 @@ {% endblock %} {% block content %} -

{% trans %}New product type{% endtrans %}

+

+ + {% trans %}New product type{% endtrans %} + + +

{% if producttype_list %} +

\n" @@ -4198,15 +4198,27 @@ msgid "There is no products in this website." msgstr "Il n'y a pas de produits dans ce site web." #: counter/templates/counter/producttype_list.jinja:4 -#: counter/templates/counter/producttype_list.jinja:26 +#: counter/templates/counter/producttype_list.jinja:42 msgid "Product type list" msgstr "Liste des types de produit" -#: counter/templates/counter/producttype_list.jinja:16 +#: counter/templates/counter/producttype_list.jinja:18 msgid "New product type" msgstr "Nouveau type de produit" -#: counter/templates/counter/producttype_list.jinja:42 +#: counter/templates/counter/producttype_list.jinja:25 +msgid "Product types are in the same order on this page and on the eboutic." +msgstr "Les types de produit sont dans le même ordre sur cette page et sur l'eboutic." + +#: counter/templates/counter/producttype_list.jinja:28 +msgid "" +"You can reorder them here by drag-and-drop. The changes will then be applied " +"globally immediately." +msgstr "" +"Vous pouvez les réorganiser ici. Les changements seront alors immédiatement " +"appliqués globalement." + +#: counter/templates/counter/producttype_list.jinja:58 msgid "There is no product types in this website." msgstr "Il n'y a pas de types de produit dans ce site web." diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index c89f7eb1..414bb603 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -123,8 +123,8 @@ msgid "captured.%s" msgstr "capture.%s" #: counter/static/bundled/counter/product-type-index.ts:36 -msgid "Products types successfully reordered" -msgstr "Types de produits réordonnés." +msgid "Products types reordered!" +msgstr "Types de produits réordonnés !" #: counter/static/bundled/counter/product-type-index.ts:40 #, javascript-format From 5da27bb26657a1012822579373beaea4065b82c4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 18 Dec 2024 12:16:24 +0100 Subject: [PATCH 8/8] rename `producttype` to `product_type` --- core/templates/core/user_tools.jinja | 2 +- counter/models.py | 2 +- counter/schemas.py | 2 +- ...cttype_list.jinja => product_type_list.jinja} | 16 ++++++++++------ counter/urls.py | 12 ++++++------ counter/views/admin.py | 3 ++- counter/views/mixins.py | 2 +- locale/fr/LC_MESSAGES/django.po | 14 +++++++------- 8 files changed, 29 insertions(+), 24 deletions(-) rename counter/templates/counter/{producttype_list.jinja => product_type_list.jinja} (77%) diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja index e00b24da..d9a6c0c7 100644 --- a/core/templates/core/user_tools.jinja +++ b/core/templates/core/user_tools.jinja @@ -52,7 +52,7 @@ %}

  • {% trans %}General counters management{% endtrans %}
  • {% trans %}Products management{% endtrans %}
  • -
  • {% trans %}Product types management{% endtrans %}
  • +
  • {% trans %}Product types management{% endtrans %}
  • {% trans %}Cash register summaries{% endtrans %}
  • {% trans %}Invoices call{% endtrans %}
  • {% trans %}Etickets{% endtrans %}
  • diff --git a/counter/models.py b/counter/models.py index b55207fb..48bb841f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -315,7 +315,7 @@ class ProductType(OrderedModel): return self.name def get_absolute_url(self): - return reverse("counter:producttype_list") + return reverse("counter:product_type_list") def is_owned_by(self, user): """Method to see if that object can be edited by the given user.""" diff --git a/counter/schemas.py b/counter/schemas.py index 1b770a3d..adc8094b 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -38,7 +38,7 @@ class ProductTypeSchema(ModelSchema): @staticmethod def resolve_url(obj: ProductType) -> str: - return reverse("counter:producttype_edit", kwargs={"type_id": obj.id}) + return reverse("counter:product_type_edit", kwargs={"type_id": obj.id}) class SimpleProductTypeSchema(ModelSchema): diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/product_type_list.jinja similarity index 77% rename from counter/templates/counter/producttype_list.jinja rename to counter/templates/counter/product_type_list.jinja index 042925df..68548829 100644 --- a/counter/templates/counter/producttype_list.jinja +++ b/counter/templates/counter/product_type_list.jinja @@ -14,12 +14,12 @@ {% block content %}

    - + {% trans %}New product type{% endtrans %}

    - {% if producttype_list %} + {% if product_types %}
    {% else %} - {% trans %}There is no product types in this website.{% endtrans %} +

    + {% trans %}There are no product types in this website.{% endtrans %} +

    {% endif %} {% endblock %} diff --git a/counter/urls.py b/counter/urls.py index 0ca77f73..91564a8b 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -121,19 +121,19 @@ urlpatterns = [ name="product_edit", ), path( - "admin/producttype/list/", + "admin/product-type/list/", ProductTypeListView.as_view(), - name="producttype_list", + name="product_type_list", ), path( - "admin/producttype/create/", + "admin/product-type/create/", ProductTypeCreateView.as_view(), - name="new_producttype", + name="new_product_type", ), path( - "admin/producttype//", + "admin/product-type//", ProductTypeEditView.as_view(), - name="producttype_edit", + name="product_type_edit", ), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), diff --git a/counter/views/admin.py b/counter/views/admin.py index c1f5c63b..aa7e2c50 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -101,8 +101,9 @@ class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): """A list view for the admins.""" model = ProductType - template_name = "counter/producttype_list.jinja" + template_name = "counter/product_type_list.jinja" current_tab = "product_types" + context_object_name = "product_types" class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): diff --git a/counter/views/mixins.py b/counter/views/mixins.py index 4a07d848..2e88f54c 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -99,7 +99,7 @@ class CounterAdminTabsMixin(TabedViewMixin): "name": _("Archived products"), }, { - "url": reverse_lazy("counter:producttype_list"), + "url": reverse_lazy("counter:product_type_list"), "slug": "product_types", "name": _("Product types"), }, diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 98b4fbd7..8ef1ade5 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -4197,20 +4197,20 @@ msgstr "Sans catégorie" msgid "There is no products in this website." msgstr "Il n'y a pas de produits dans ce site web." -#: counter/templates/counter/producttype_list.jinja:4 -#: counter/templates/counter/producttype_list.jinja:42 +#: counter/templates/counter/product_type_list.jinja:4 +#: counter/templates/counter/product_type_list.jinja:42 msgid "Product type list" msgstr "Liste des types de produit" -#: counter/templates/counter/producttype_list.jinja:18 +#: counter/templates/counter/product_type_list.jinja:18 msgid "New product type" msgstr "Nouveau type de produit" -#: counter/templates/counter/producttype_list.jinja:25 +#: counter/templates/counter/product_type_list.jinja:25 msgid "Product types are in the same order on this page and on the eboutic." msgstr "Les types de produit sont dans le même ordre sur cette page et sur l'eboutic." -#: counter/templates/counter/producttype_list.jinja:28 +#: counter/templates/counter/product_type_list.jinja:28 msgid "" "You can reorder them here by drag-and-drop. The changes will then be applied " "globally immediately." @@ -4218,8 +4218,8 @@ msgstr "" "Vous pouvez les réorganiser ici. Les changements seront alors immédiatement " "appliqués globalement." -#: counter/templates/counter/producttype_list.jinja:58 -msgid "There is no product types in this website." +#: counter/templates/counter/product_type_list.jinja:58 +msgid "There are no product types in this website." msgstr "Il n'y a pas de types de produit dans ce site web." #: counter/templates/counter/refilling_list.jinja:15