Merge pull request #952 from ae-utbm/sort-producttypes

Sort product types
This commit is contained in:
thomas girod 2024-12-18 15:45:50 +01:00 committed by GitHub
commit fad470b670
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 800 additions and 267 deletions

View File

@ -1,5 +1,7 @@
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin(sort);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {

View File

@ -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,

View File

@ -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%;
}
}

View File

@ -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;

View File

@ -52,7 +52,7 @@
%}
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
<li><a href="{{ url('counter:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
<li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>

View File

@ -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)

View File

@ -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)
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()
)
.filter(archived=False)
.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)

View File

@ -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"),
]

View File

@ -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."""

View File

@ -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")

View File

@ -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 `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
}
}

View File

@ -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<HTMLLIElement>;
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;
},
}));
});

View File

@ -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;
}

View File

@ -0,0 +1,64 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product type list{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("counter/css/product_type.scss") }}">
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/counter/product-type-index.ts") }}"></script>
{% endblock %}
{% block content %}
<p>
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
{% trans %}New product type{% endtrans %}
<i class="fa fa-plus"></i>
</a>
</p>
{% if product_types %}
<aside>
<p>
{% trans %}Product types are in the same order on this page and on the eboutic.{% endtrans %}
</p>
<p>
{% trans trimmed %}
You can reorder them here by drag-and-drop.
The changes will then be applied globally immediately.
{% endtrans %}
</p>
</aside>
<div x-data="productTypesList">
<p
class="alert snackbar"
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
x-transition.duration.500ms
x-text="alertMessage.content"
></p>
<h3>{% trans %}Product type list{% endtrans %}</h3>
<ul
x-sort="($item, $position) => reorder($item, $position)"
x-ref="productTypes"
class="product-type-list"
:aria-busy="loading"
>
{%- for product_type in product_types -%}
<li x-sort:item="{{ product_type.id }}">
<i class="fa fa-grip-vertical"></i>
<a href="{{ url('counter:product_type_edit', type_id=product_type.id) }}">
{{ product_type.name }}
</a>
</li>
{%- endfor -%}
</ul>
</div>
{% else %}
<p>
{% trans %}There are no product types in this website.{% endtrans %}
</p>
{% endif %}
{% endblock %}

View File

@ -1,24 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product type list{% endtrans %}
{% endblock %}
{% block content %}
<p><a href="{{ url('counter:new_producttype') }}">{% trans %}New product type{% endtrans %}</a></p>
{% if producttype_list %}
<h3>{% trans %}Product type list{% endtrans %}</h3>
<ul>
{% for t in producttype_list %}
<li><a href="{{ url('counter:producttype_edit', type_id=t.id) }}">{{ t }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no product types in this website.{% endtrans %}
{% endif %}
{% endblock %}

View File

@ -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"))

View File

@ -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

View File

@ -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/<int:type_id>/",
"admin/product-type/<int:type_id>/",
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"),

View File

@ -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",
]

View File

@ -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"),
},

View File

@ -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

View File

@ -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

View File

@ -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`

View File

@ -88,7 +88,7 @@
</div>
{% 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 %}
<section>

View File

@ -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 <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\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

View File

@ -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 <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\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"

16
package-lock.json generated
View File

@ -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"
}

View File

@ -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",