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/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts
index 674b7b73..06c3508e 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,
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/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/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 f3f0f101..dd7b75f0 100644
--- a/counter/api.py
+++ b/counter/api.py
@@ -12,24 +12,33 @@
# 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 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, IsRoot
-from counter.models import Counter, Product
+from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
+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):
@@ -64,15 +73,72 @@ 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__order").asc(nulls_last=True),
+ "product_type",
+ "name",
+ ).values()
)
+
+ @route.get(
+ "/search/detailed",
+ response=PaginatedResponseSchema[ProductSchema],
+ permissions=[IsCounterAdmin],
+ 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__order").asc(nulls_last=True),
+ "product_type",
+ "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/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..48bb841f 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,32 +290,32 @@ 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
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 ec1a842d..adc8094b 100644
--- a/counter/schemas.py
+++ b/counter/schemas.py
@@ -1,10 +1,13 @@
-from typing import Annotated
+from typing import Annotated, Self
from annotated_types import MinLen
-from ninja import Field, FilterSchema, ModelSchema
+from django.urls import reverse
+from ninja import Field, FilterSchema, ModelSchema, Schema
+from pydantic import model_validator
-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 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
fields = ["id", "name"]
-class ProductSchema(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:product_type_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
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: SimpleProductTypeSchema | 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/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/product_type_list.jinja b/counter/templates/counter/product_type_list.jinja
new file mode 100644
index 00000000..68548829
--- /dev/null
+++ b/counter/templates/counter/product_type_list.jinja
@@ -0,0 +1,64 @@
+{% extends "core/base.jinja" %}
+
+{% block title %}
+ {% trans %}Product type list{% endtrans %}
+{% endblock %}
+
+{% block additional_css %}
+
+{% endblock %}
+
+{% block additional_js %}
+
+{% endblock %}
+
+{% block content %}
+
+
+ {% trans %}New product type{% endtrans %}
+
+
+
+ {% if product_types %}
+
+
+
+
{% trans %}Product type list{% endtrans %}
+
+
+ {% else %}
+
+ {% trans %}There are no product types in this website.{% endtrans %}
+
+ {% endif %}
+{% endblock %}
diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/producttype_list.jinja
deleted file mode 100644
index 0c4ff0c5..00000000
--- a/counter/templates/counter/producttype_list.jinja
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends "core/base.jinja" %}
-
-{% block title %}
- {% trans %}Product type list{% endtrans %}
-{% endblock %}
-
-{% block content %}
- {% trans %}New product type{% endtrans %}
- {% if producttype_list %}
- {% 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 %}
-
-
-
-
-
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/tests/test_product_type.py b/counter/tests/test_product_type.py
new file mode 100644
index 00000000..7976ef85
--- /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
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 fbf466b3..aa7e2c50 100644
--- a/counter/views/admin.py
+++ b/counter/views/admin.py
@@ -101,15 +101,16 @@ 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):
"""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 +120,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 +130,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/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/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
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
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..8ef1ade5 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 17:04+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"
@@ -4193,17 +4197,29 @@ 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:10
+#: 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:8
+#: counter/templates/counter/product_type_list.jinja:18
msgid "New product type"
msgstr "Nouveau type de produit"
-#: counter/templates/counter/producttype_list.jinja:17
-msgid "There is no product types in this website."
+#: 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/product_type_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/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
diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po
index e907e571..414bb603 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 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",