diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51b4f75d..e96a456f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.0 + rev: v0.15.5 hooks: - id: ruff-check # just check the code, and print the errors - id: ruff-check # actually fix the fixable errors, but print nothing @@ -12,7 +12,7 @@ repos: rev: v0.6.1 hooks: - id: biome-check - additional_dependencies: ["@biomejs/biome@2.3.14"] + additional_dependencies: ["@biomejs/biome@2.4.6"] - repo: https://github.com/rtts/djhtml rev: 3.0.10 hooks: diff --git a/club/api.py b/club/api.py index 1479ee5c..3ed425bf 100644 --- a/club/api.py +++ b/club/api.py @@ -6,9 +6,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema from api.auth import ApiKeyAuth -from api.permissions import CanAccessLookup, HasPerm +from api.permissions import CanAccessLookup, CanView, HasPerm from club.models import Club, Membership -from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema +from club.schemas import ( + ClubSchema, + ClubSearchFilterSchema, + SimpleClubSchema, + UserMembershipSchema, +) +from core.models import User @api_controller("/club") @@ -38,3 +44,22 @@ class ClubController(ControllerBase): return self.get_object_or_exception( Club.objects.prefetch_related(prefetch), id=club_id ) + + +@api_controller("/user/{int:user_id}/club") +class UserClubController(ControllerBase): + @route.get( + "", + response=list[UserMembershipSchema], + auth=[ApiKeyAuth(), SessionAuth()], + permissions=[CanView], + url_name="fetch_user_clubs", + ) + def fetch_user_clubs(self, user_id: int): + """Get all the active memberships of the given user.""" + user = self.get_object_or_exception(User, id=user_id) + return ( + Membership.objects.ongoing() + .filter(user=user) + .select_related("club", "user") + ) diff --git a/club/schemas.py b/club/schemas.py index 9483d4c6..08488c31 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -40,6 +40,8 @@ class ClubProfileSchema(ModelSchema): class ClubMemberSchema(ModelSchema): + """A schema to represent all memberships in a club.""" + class Meta: model = Membership fields = ["start_date", "end_date", "role", "description"] @@ -53,3 +55,13 @@ class ClubSchema(ModelSchema): fields = ["id", "name", "logo", "is_active", "short_description", "address"] members: list[ClubMemberSchema] + + +class UserMembershipSchema(ModelSchema): + """A schema to represent the active club memberships of a user.""" + + class Meta: + model = Membership + fields = ["id", "start_date", "role", "description"] + + club: SimpleClubSchema diff --git a/club/templates/club/club_sellings.jinja b/club/templates/club/club_sellings.jinja index 59edd18e..5ed8afc6 100644 --- a/club/templates/club/club_sellings.jinja +++ b/club/templates/club/club_sellings.jinja @@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one {% csrf_token %} {{ form }}

-

+

{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}
diff --git a/club/tests/test_user_club_controller.py b/club/tests/test_user_club_controller.py new file mode 100644 index 00000000..2aba7225 --- /dev/null +++ b/club/tests/test_user_club_controller.py @@ -0,0 +1,50 @@ +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils.timezone import localdate +from model_bakery import baker +from model_bakery.recipe import Recipe + +from club.models import Club, Membership +from club.schemas import UserMembershipSchema +from core.baker_recipes import subscriber_user +from core.models import Page + + +class TestFetchClub(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = subscriber_user.make() + pages = baker.make(Page, _quantity=3, _bulk_create=True) + clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True) + recipe = Recipe( + Membership, user=cls.user, start_date=localdate() - timedelta(days=2) + ) + cls.members = Membership.objects.bulk_create( + [ + recipe.prepare(club=clubs[0]), + recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)), + recipe.prepare(club=clubs[1]), + ] + ) + + def test_fetch_memberships(self): + self.client.force_login(subscriber_user.make()) + res = self.client.get( + reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id}) + ) + assert res.status_code == 200 + assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [ + UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2]) + ] + + def test_fetch_club_nb_queries(self): + self.client.force_login(subscriber_user.make()) + with self.assertNumQueries(6): + # - 5 queries for authentication + # - 1 query for the actual data + res = self.client.get( + reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id}) + ) + assert res.status_code == 200 diff --git a/core/auth/mixins.py b/core/auth/mixins.py index 917200ed..28012d50 100644 --- a/core/auth/mixins.py +++ b/core/auth/mixins.py @@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin): return False if super().has_permission(): return True - return self.club is not None and any( - g.id == self.club.board_group_id for g in self.request.user.cached_groups + return ( + self.club is not None + and self.club.board_group_id in self.request.user.all_groups ) diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index 34f51c80..562a46ad 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -12,7 +12,7 @@ from django.utils.timezone import localdate, make_aware, now from faker import Faker from club.models import Club, Membership -from core.models import Group, User +from core.models import Group, User, UserBan from counter.models import ( Counter, Customer, @@ -40,6 +40,7 @@ class Command(BaseCommand): self.stdout.write("Creating users...") users = self.create_users() + self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users subscribers = random.sample(users, k=int(0.8 * len(users))) self.stdout.write("Creating subscriptions...") self.create_subscriptions(subscribers) @@ -88,6 +89,8 @@ class Command(BaseCommand): self.stdout.write("Done") def create_users(self) -> list[User]: + # Create a single password hash for all users to make it faster. + # It's insecure as hell, but it's ok since it's only for dev purposes. password = make_password("plop") users = [ User( @@ -114,14 +117,33 @@ class Command(BaseCommand): public_group.users.add(*users) return users + def create_bans(self, users: list[User]): + ban_groups = [ + settings.SITH_GROUP_BANNED_COUNTER_ID, + settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID, + settings.SITH_GROUP_BANNED_ALCOHOL_ID, + ] + UserBan.objects.bulk_create( + [ + UserBan( + user=user, + ban_group_id=i, + reason=self.faker.sentence(), + expires_at=make_aware(self.faker.future_datetime("+1y")), + ) + for user in users + for i in random.sample(ban_groups, k=random.randint(1, len(ban_groups))) + ] + ) + def create_subscriptions(self, users: list[User]): def prepare_subscription(_user: User, start_date: date) -> Subscription: payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0] duration = random.randint(1, 4) - sub = Subscription(member=_user, payment_method=payment_method) - sub.subscription_start = sub.compute_start(d=start_date, duration=duration) - sub.subscription_end = sub.compute_end(duration) - return sub + s = Subscription(member=_user, payment_method=payment_method) + s.subscription_start = s.compute_start(d=start_date, duration=duration) + s.subscription_end = s.compute_end(duration) + return s subscriptions = [] customers = [] diff --git a/core/models.py b/core/models.py index 27744775..3b533751 100644 --- a/core/models.py +++ b/core/models.py @@ -356,23 +356,27 @@ class User(AbstractUser): ) if group_id is None: return False - if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID: - return self.is_subscribed - if group_id == settings.SITH_GROUP_ROOT_ID: - return self.is_root - return any(g.id == group_id for g in self.cached_groups) + return group_id in self.all_groups @cached_property - def cached_groups(self) -> list[Group]: + def all_groups(self) -> dict[int, Group]: """Get the list of groups this user is in.""" - return list(self.groups.all()) + additional_groups = [] + if self.is_subscribed: + additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID) + if self.is_superuser: + additional_groups.append(settings.SITH_GROUP_ROOT_ID) + qs = self.groups.all() + if additional_groups: + # This is somewhat counter-intuitive, but this query runs way faster with + # a UNION rather than a OR (in average, 0.25ms vs 14ms). + # For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union + qs = qs.union(Group.objects.filter(id__in=additional_groups)) + return {g.id: g for g in qs} @cached_property def is_root(self) -> bool: - if self.is_superuser: - return True - root_id = settings.SITH_GROUP_ROOT_ID - return any(g.id == root_id for g in self.cached_groups) + return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups @cached_property def is_board_member(self) -> bool: @@ -1099,10 +1103,7 @@ class PageQuerySet(models.QuerySet): return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID) if user.has_perm("core.view_page"): return self.all() - groups_ids = [g.id for g in user.cached_groups] - if user.is_subscribed: - groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID) - return self.filter(view_groups__in=groups_ids) + return self.filter(view_groups__in=user.all_groups) # This function prevents generating migration upon settings change @@ -1376,7 +1377,7 @@ class PageRev(models.Model): return self.page.can_be_edited_by(user) def is_owned_by(self, user: User) -> bool: - return any(g.id == self.page.owner_group_id for g in user.cached_groups) + return self.page.owner_group_id in user.all_groups def similarity_ratio(self, text: str) -> float: """Similarity ratio between this revision's content and the given text. diff --git a/core/static/bundled/core/dynamic-formset-index.ts b/core/static/bundled/core/dynamic-formset-index.ts new file mode 100644 index 00000000..6b71e25f --- /dev/null +++ b/core/static/bundled/core/dynamic-formset-index.ts @@ -0,0 +1,77 @@ +interface Config { + /** + * The prefix of the formset, in case it has been changed. + * See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix + */ + prefix?: string; +} + +// biome-ignore lint/style/useNamingConvention: It's the DOM API naming +type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +document.addEventListener("alpine:init", () => { + /** + * Alpine data element to allow the dynamic addition of forms to a formset. + * + * To use this, you need : + * - an HTML element containing the existing forms, noted by `x-ref="formContainer"` + * - a template containing the empty form + * (that you can obtain jinja-side with `{{ formset.empty_form }}`), + * noted by `x-ref="formTemplate"` + * - a button with `@click="addForm"` + * - you may also have one or more buttons with `@click="removeForm(element)"`, + * where `element` is the HTML element containing the form. + * + * For an example of how this is used, you can have a look to + * `counter/templates/counter/product_form.jinja` + */ + Alpine.data("dynamicFormSet", (config?: Config) => ({ + init() { + this.formContainer = this.$refs.formContainer as HTMLElement; + this.nbForms = this.formContainer.children.length as number; + this.template = this.$refs.formTemplate as HTMLTemplateElement; + const prefix = config?.prefix ?? "form"; + this.$root + .querySelector(`#id_${prefix}-TOTAL_FORMS`) + .setAttribute(":value", "nbForms"); + }, + + addForm() { + this.formContainer.appendChild(document.importNode(this.template.content, true)); + const newForm = this.formContainer.lastElementChild; + const inputs: NodeListOf = newForm.querySelectorAll( + "input, select, textarea", + ); + for (const el of inputs) { + el.name = el.name.replace("__prefix__", this.nbForms.toString()); + el.id = el.id.replace("__prefix__", this.nbForms.toString()); + } + const labels: NodeListOf = newForm.querySelectorAll("label"); + for (const el of labels) { + el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString()); + } + inputs[0].focus(); + this.nbForms += 1; + }, + + removeForm(container: HTMLDivElement) { + container.remove(); + this.nbForms -= 1; + // adjust the id of remaining forms + for (let i = 0; i < this.nbForms; i++) { + const form: HTMLDivElement = this.formContainer.children[i]; + const inputs: NodeListOf = form.querySelectorAll( + "input, select, textarea", + ); + for (const el of inputs) { + el.name = el.name.replace(/\d+/, i.toString()); + el.id = el.id.replace(/\d+/, i.toString()); + } + const labels: NodeListOf = form.querySelectorAll("label"); + for (const el of labels) { + el.htmlFor = el.htmlFor.replace(/\d+/, i.toString()); + } + } + }, + })); +}); diff --git a/core/static/core/base.css b/core/static/core/base.css index e8e7e9ac..d9bd6af1 100644 --- a/core/static/core/base.css +++ b/core/static/core/base.css @@ -115,7 +115,6 @@ blockquote:before, blockquote:after, q:before, q:after { - content: ""; content: none; } table { diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index 34a8040b..025dacdf 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -35,8 +35,8 @@ - - + + diff --git a/core/templates/core/delete_confirm.jinja b/core/templates/core/delete_confirm.jinja index 6ae8a1b2..70fcd764 100644 --- a/core/templates/core/delete_confirm.jinja +++ b/core/templates/core/delete_confirm.jinja @@ -21,6 +21,8 @@

{% trans %}Delete confirmation{% endtrans %}

{% csrf_token %}

{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}

+ {% if help_text %}

{{ help_text }}

{% endif %} +
diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 631e5b51..f6dc8570 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase): group_in = baker.make(Group) self.public_user.groups.add(group_in) - # clear the cached property `User.cached_groups` - self.public_user.__dict__.pop("cached_groups", None) + # clear the cached property `User.all_groups` + self.public_user.__dict__.pop("all_groups", None) # Test when the user is in the group - with self.assertNumQueries(1): + with self.assertNumQueries(2): self.public_user.is_in_group(pk=group_in.id) with self.assertNumQueries(0): self.public_user.is_in_group(pk=group_in.id) group_not_in = baker.make(Group) - self.public_user.__dict__.pop("cached_groups", None) + self.public_user.__dict__.pop("all_groups", None) # Test when the user is not in the group with self.assertNumQueries(1): self.public_user.is_in_group(pk=group_not_in.id) diff --git a/counter/admin.py b/counter/admin.py index ed24dd23..425134ef 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -39,8 +39,9 @@ class ProductAdmin(SearchModelAdmin): "code", "product_type", "selling_price", - "profit", "archived", + "created_at", + "updated_at", ) list_select_related = ("product_type",) search_fields = ("name", "code") diff --git a/counter/forms.py b/counter/forms.py index ed47232a..52b7bae2 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -5,6 +5,8 @@ from datetime import date, datetime, timezone from dateutil.relativedelta import relativedelta from django import forms +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.utils.timezone import now @@ -14,7 +16,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget from club.models import Club from club.widgets.ajax_select import AutoCompleteSelectClub -from core.models import User +from core.models import User, UserQuerySet from core.views.forms import ( FutureDateTimeField, NFCTextInput, @@ -23,6 +25,7 @@ from core.views.forms import ( ) from core.views.widgets.ajax_select import ( AutoCompleteSelect, + AutoCompleteSelectMultiple, AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleUser, AutoCompleteSelectUser, @@ -30,10 +33,12 @@ from core.views.widgets.ajax_select import ( from counter.models import ( BillingInfo, Counter, + CounterSellers, Customer, Eticket, InvoiceCall, Product, + ProductFormula, Refilling, ReturnableProduct, ScheduledProductAction, @@ -167,12 +172,102 @@ class RefillForm(forms.ModelForm): class CounterEditForm(forms.ModelForm): class Meta: model = Counter - fields = ["sellers", "products"] + fields = ["products"] - widgets = { - "sellers": AutoCompleteSelectMultipleUser, - "products": AutoCompleteSelectMultipleProduct, - } + sellers_regular = forms.ModelMultipleChoiceField( + label=_("Regular barmen"), + help_text=_( + "Barmen having regular permanences " + "or frequently giving a hand throughout the semester." + ), + queryset=User.objects.all(), + widget=AutoCompleteSelectMultipleUser, + required=False, + ) + sellers_temporary = forms.ModelMultipleChoiceField( + label=_("Temporary barmen"), + help_text=_( + "Barmen who will be there only for a limited period (e.g. for one evening)" + ), + queryset=User.objects.all(), + widget=AutoCompleteSelectMultipleUser, + required=False, + ) + field_order = ["sellers_regular", "sellers_temporary", "products"] + + def __init__(self, *args, user: User, instance: Counter, **kwargs): + super().__init__(*args, instance=instance, **kwargs) + # if the user is an admin, he will have access to all products, + # else only to active products owned by the counter's club + # or already on the counter + if user.has_perm("counter.change_counter"): + self.fields["products"].widget = AutoCompleteSelectMultipleProduct() + else: + # updating the queryset of the field also updates the choices of + # the widget, so it's important to set the queryset after the widget + self.fields["products"].widget = AutoCompleteSelectMultiple() + self.fields["products"].queryset = Product.objects.filter( + Q(club_id=instance.club_id) | Q(counters=instance), archived=False + ).distinct() + self.fields["products"].help_text = _( + "If you want to add a product that is not owned by " + "your club to this counter, you should ask an admin." + ) + self.fields["sellers_regular"].initial = self.instance.sellers.filter( + countersellers__is_regular=True + ).all() + self.fields["sellers_temporary"].initial = self.instance.sellers.filter( + countersellers__is_regular=False + ).all() + + def clean(self): + regular: UserQuerySet = self.cleaned_data["sellers_regular"] + temporary: UserQuerySet = self.cleaned_data["sellers_temporary"] + duplicates = list(regular.intersection(temporary)) + if duplicates: + raise ValidationError( + _( + "A user cannot be a regular and a temporary barman " + "at the same time, " + "but the following users have been defined as both : %(users)s" + ) + % {"users": ", ".join([u.get_display_name() for u in duplicates])} + ) + return self.cleaned_data + + def save_sellers(self): + sellers = [] + for users, is_regular in ( + (self.cleaned_data["sellers_regular"], True), + (self.cleaned_data["sellers_temporary"], False), + ): + sellers.extend( + [ + CounterSellers(counter=self.instance, user=u, is_regular=is_regular) + for u in users + ] + ) + # start by deleting removed CounterSellers objects + user_ids = [seller.user.id for seller in sellers] + CounterSellers.objects.filter( + ~Q(user_id__in=user_ids), counter=self.instance + ).delete() + + # then create or update the new barmen + CounterSellers.objects.bulk_create( + sellers, + update_conflicts=True, + update_fields=["is_regular"], + unique_fields=["user", "counter"], + ) + + def save(self, commit=True): # noqa: FBT002 + self.instance = super().save(commit=commit) + if commit and any( + key in self.changed_data for key in ("sellers_regular", "sellers_temporary") + ): + self.save_sellers() + return self.instance class ScheduledProductActionForm(forms.ModelForm): @@ -278,7 +373,8 @@ ScheduledProductActionFormSet = forms.modelformset_factory( absolute_max=None, can_delete=True, can_delete_extra=False, - extra=2, + extra=0, + min_num=1, ) @@ -316,7 +412,6 @@ class ProductForm(forms.ModelForm): } counters = forms.ModelMultipleChoiceField( - help_text=None, label=_("Counters"), required=False, widget=AutoCompleteSelectMultipleCounter, @@ -327,10 +422,31 @@ class ProductForm(forms.ModelForm): super().__init__(*args, instance=instance, **kwargs) if self.instance.id: self.fields["counters"].initial = self.instance.counters.all() + if hasattr(self.instance, "formula"): + self.formula_init(self.instance.formula) self.action_formset = ScheduledProductActionFormSet( *args, product=self.instance, **kwargs ) + def formula_init(self, formula: ProductFormula): + """Part of the form initialisation specific to formula products.""" + self.fields["selling_price"].help_text = _( + "This product is a formula. " + "Its price cannot be greater than the price " + "of the products constituting it, which is %(price)s €" + ) % {"price": formula.max_selling_price} + self.fields["special_selling_price"].help_text = _( + "This product is a formula. " + "Its special price cannot be greater than the price " + "of the products constituting it, which is %(price)s €" + ) % {"price": formula.max_special_selling_price} + for key, price in ( + ("selling_price", formula.max_selling_price), + ("special_selling_price", formula.max_special_selling_price), + ): + self.fields[key].widget.attrs["max"] = price + self.fields[key].validators.append(MaxValueValidator(price)) + def is_valid(self): return super().is_valid() and self.action_formset.is_valid() @@ -349,13 +465,47 @@ class ProductForm(forms.ModelForm): return product +class ProductFormulaForm(forms.ModelForm): + class Meta: + model = ProductFormula + fields = ["products", "result"] + widgets = { + "products": AutoCompleteSelectMultipleProduct, + "result": AutoCompleteSelectProduct, + } + + def clean(self): + cleaned_data = super().clean() + if cleaned_data["result"] in cleaned_data["products"]: + self.add_error( + None, + _( + "The same product cannot be at the same time " + "the result and a part of the formula." + ), + ) + prices = [p.selling_price for p in cleaned_data["products"]] + special_prices = [p.special_selling_price for p in cleaned_data["products"]] + selling_price = cleaned_data["result"].selling_price + special_selling_price = cleaned_data["result"].special_selling_price + if selling_price > sum(prices) or special_selling_price > sum(special_prices): + self.add_error( + "result", + _( + "The result cannot be more expensive " + "than the total of the other products." + ), + ) + return cleaned_data + + class ReturnableProductForm(forms.ModelForm): class Meta: model = ReturnableProduct fields = ["product", "returned_product", "max_return"] widgets = { - "product": AutoCompleteSelectProduct(), - "returned_product": AutoCompleteSelectProduct(), + "product": AutoCompleteSelectProduct, + "returned_product": AutoCompleteSelectProduct, } def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT diff --git a/counter/migrations/0036_product_created_at_product_updated_at.py b/counter/migrations/0036_product_created_at_product_updated_at.py new file mode 100644 index 00000000..5fe622f2 --- /dev/null +++ b/counter/migrations/0036_product_created_at_product_updated_at.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.8 on 2026-02-10 15:40 +from operator import attrgetter + +import django.utils.timezone +from django.db import migrations, models +from django.db.migrations.state import StateApps +from django.db.models import OuterRef, Subquery + +from counter.models import Selling + + +def apply_product_history_dates(apps: StateApps, schema_editor): + """Approximate a posteriori the value of created_at and updated_at.""" + Product = apps.get_model("counter", "Product") + sales_subquery = Selling.objects.filter(product=OuterRef("pk")).values("date") + + # for products that have an associated sale, we set the creation date + # to the one of the first sale, and the update date to the one of the last sale + products = list( + Product.objects.exclude(sellings=None) + .annotate( + new_created_at=Subquery(sales_subquery.order_by("date")[:1]), + new_updated_at=Subquery(sales_subquery.order_by("-date")[:1]), + ) + .only("id") + ) + for product in products: + product.created_at = product.new_created_at + product.updated_at = product.new_updated_at + + # For the remaining products (those without sale), + # they are given the creation and update date of the previous product having sales. + products_without_sale = list(Product.objects.filter(sellings=None).only("id")) + for product in products_without_sale: + previous_product = max( + (p for p in products if p.id < product.id), key=attrgetter("id") + ) + product.created_at = previous_product.created_at + product.updated_at = previous_product.updated_at + products.extend(products_without_sale) + + Product.objects.bulk_update(products, fields=["created_at", "updated_at"]) + + +class Migration(migrations.Migration): + dependencies = [("counter", "0035_remove_selling_is_validated_and_more")] + + operations = [ + migrations.AddField( + model_name="product", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="created at", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="product", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + migrations.RunPython( + apply_product_history_dates, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/counter/migrations/0037_productformula.py b/counter/migrations/0037_productformula.py new file mode 100644 index 00000000..75fbdd7f --- /dev/null +++ b/counter/migrations/0037_productformula.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.8 on 2025-11-26 11:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("counter", "0036_product_created_at_product_updated_at")] + + operations = [ + migrations.CreateModel( + name="ProductFormula", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "products", + models.ManyToManyField( + help_text="The products that constitute this formula.", + related_name="formulas", + to="counter.product", + verbose_name="products", + ), + ), + ( + "result", + models.OneToOneField( + help_text="The formula product.", + on_delete=django.db.models.deletion.CASCADE, + to="counter.product", + verbose_name="result product", + ), + ), + ], + ), + ] diff --git a/counter/migrations/0038_countersellers.py b/counter/migrations/0038_countersellers.py new file mode 100644 index 00000000..48151573 --- /dev/null +++ b/counter/migrations/0038_countersellers.py @@ -0,0 +1,88 @@ +# Generated by Django 5.2.11 on 2026-03-04 15:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("counter", "0037_productformula"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # cf. https://docs.djangoproject.com/fr/stable/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunSQL( + sql="ALTER TABLE counter_counter_sellers RENAME TO counter_countersellers", + reverse_sql="ALTER TABLE counter_countersellers RENAME TO counter_counter_sellers", + ), + ], + state_operations=[ + migrations.CreateModel( + name="CounterSellers", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "counter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="counter.counter", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("counter", "user"), + name="counter_counter_sellers_counter_id_subscriber_id_key", + ) + ], + }, + ), + migrations.AlterField( + model_name="counter", + name="sellers", + field=models.ManyToManyField( + blank=True, + related_name="counters", + through="counter.CounterSellers", + to=settings.AUTH_USER_MODEL, + verbose_name="sellers", + ), + ), + ], + ), + migrations.AddField( + model_name="countersellers", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="created at", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="countersellers", + name="is_regular", + field=models.BooleanField(default=False, verbose_name="regular barman"), + ), + ] diff --git a/counter/models.py b/counter/models.py index 6ceb9ea8..29ecad2f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -399,6 +399,8 @@ class Product(models.Model): Group, related_name="products", verbose_name=_("buying groups"), blank=True ) archived = models.BooleanField(_("archived"), default=False) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(_("updated at"), auto_now=True) class Meta: verbose_name = _("product") @@ -454,6 +456,37 @@ class Product(models.Model): return self.selling_price - self.purchase_price +class ProductFormula(models.Model): + products = models.ManyToManyField( + Product, + related_name="formulas", + verbose_name=_("products"), + help_text=_("The products that constitute this formula."), + ) + result = models.OneToOneField( + Product, + related_name="formula", + on_delete=models.CASCADE, + verbose_name=_("result product"), + help_text=_("The product got with the formula."), + ) + + def __str__(self): + return self.result.name + + @cached_property + def max_selling_price(self) -> float: + # iterating over all products is less efficient than doing + # a simple aggregation, but this method is likely to be used in + # coordination with `max_special_selling_price`, + # and Django caches the result of the `all` queryset. + return sum(p.selling_price for p in self.products.all()) + + @cached_property + def max_special_selling_price(self) -> float: + return sum(p.special_selling_price for p in self.products.all()) + + class CounterQuerySet(models.QuerySet): def annotate_has_barman(self, user: User) -> Self: """Annotate the queryset with the `user_is_barman` field. @@ -518,7 +551,11 @@ class Counter(models.Model): choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))], ) sellers = models.ManyToManyField( - User, verbose_name=_("sellers"), related_name="counters", blank=True + User, + verbose_name=_("sellers"), + related_name="counters", + blank=True, + through="CounterSellers", ) edit_groups = models.ManyToManyField( Group, related_name="editable_counters", blank=True @@ -710,6 +747,26 @@ class Counter(models.Model): ] +class CounterSellers(models.Model): + """Custom through model for the counter-sellers M2M relationship.""" + + counter = models.ForeignKey(Counter, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + is_regular = models.BooleanField(_("regular barman"), default=False) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["counter", "user"], + name="counter_counter_sellers_counter_id_subscriber_id_key", + ) + ] + + def __str__(self): + return f"counter {self.counter_id} - user {self.user_id}" + + class RefillingQuerySet(models.QuerySet): def annotate_total(self) -> Self: """Annotate the Queryset with the total amount. diff --git a/counter/static/bundled/counter/components/ajax-select-index.ts b/counter/static/bundled/counter/components/ajax-select-index.ts index cd9f77db..5470de25 100644 --- a/counter/static/bundled/counter/components/ajax-select-index.ts +++ b/counter/static/bundled/counter/components/ajax-select-index.ts @@ -18,7 +18,10 @@ export class ProductAjaxSelect extends AjaxSelect { protected searchField = ["code", "name"]; protected async search(query: string): Promise { - const resp = await productSearchProducts({ query: { search: query } }); + const resp = await productSearchProducts({ + // biome-ignore lint/style/useNamingConvention: API is snake_case + query: { search: query, is_archived: false }, + }); if (resp.data) { return resp.data.results; } diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 6d582b20..88f8627d 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,6 +1,10 @@ import { AlertMessage } from "#core:utils/alert-message.ts"; import { BasketItem } from "#counter:counter/basket.ts"; -import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts"; +import type { + CounterConfig, + ErrorMessage, + ProductFormula, +} from "#counter:counter/types.ts"; import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; document.addEventListener("alpine:init", () => { @@ -47,15 +51,43 @@ document.addEventListener("alpine:init", () => { this.basket[id] = item; + this.checkFormulas(); + if (this.sumBasket() > this.customerBalance) { item.quantity = oldQty; if (item.quantity === 0) { delete this.basket[id]; } - return gettext("Not enough money"); + this.alertMessage.display(gettext("Not enough money"), { success: false }); } + }, - return ""; + checkFormulas() { + const products = new Set( + Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)), + ); + const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { + return f.products.every((p: number) => products.has(p)); + }); + if (formula === undefined) { + return; + } + for (const product of formula.products) { + const key = product.toString(); + this.basket[key].quantity -= 1; + if (this.basket[key].quantity <= 0) { + this.removeFromBasket(key); + } + } + this.alertMessage.display( + interpolate( + gettext("Formula %(formula)s applied"), + { formula: config.products[formula.result.toString()].name }, + true, + ), + { success: true }, + ); + this.addToBasket(formula.result.toString(), 1); }, getBasketSize() { @@ -70,14 +102,7 @@ document.addEventListener("alpine:init", () => { (acc: number, cur: BasketItem) => acc + cur.sum(), 0, ) as number; - return total; - }, - - addToBasketWithMessage(id: string, quantity: number) { - const message = this.addToBasket(id, quantity); - if (message.length > 0) { - this.alertMessage.display(message, { success: false }); - } + return Math.round(total * 100) / 100; }, onRefillingSuccess(event: CustomEvent) { @@ -116,7 +141,7 @@ document.addEventListener("alpine:init", () => { this.finish(); } } else { - this.addToBasketWithMessage(code, quantity); + this.addToBasket(code, quantity); } this.codeField.widget.clear(); this.codeField.widget.focus(); diff --git a/counter/static/bundled/counter/types.d.ts b/counter/static/bundled/counter/types.d.ts index 18fea258..330b6f0e 100644 --- a/counter/static/bundled/counter/types.d.ts +++ b/counter/static/bundled/counter/types.d.ts @@ -7,10 +7,16 @@ export interface InitialFormData { errors?: string[]; } +export interface ProductFormula { + result: number; + products: number[]; +} + export interface CounterConfig { customerBalance: number; customerId: number; products: Record; + formulas: ProductFormula[]; formInitial: InitialFormData[]; cancelUrl: string; } diff --git a/counter/static/counter/css/counter-click.scss b/counter/static/counter/css/counter-click.scss index 478fa32c..6288f902 100644 --- a/counter/static/counter/css/counter-click.scss +++ b/counter/static/counter/css/counter-click.scss @@ -10,12 +10,12 @@ float: right; } -.basket-error-container { +.basket-message-container { position: relative; display: block } -.basket-error { +.basket-message { z-index: 10; // to get on top of tomselect text-align: center; position: absolute; diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index dc38d8a3..07bfd461 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -32,13 +32,11 @@
-
{% trans %}Customer{% endtrans %}
@@ -88,11 +86,12 @@ -
+
@@ -111,9 +110,9 @@
- + - + : @@ -213,7 +212,7 @@
{{ category }}
{% for product in categories[category] -%} - + {% endif %} + + {%- endfor -%} +
+ +{% endblock %} diff --git a/counter/templates/counter/product_form.jinja b/counter/templates/counter/product_form.jinja index 00459b41..ffd751c0 100644 --- a/counter/templates/counter/product_form.jinja +++ b/counter/templates/counter/product_form.jinja @@ -1,8 +1,49 @@ {% extends "core/base.jinja" %} +{% block additional_js %} + +{% endblock %} + + +{% macro action_form(form) %} +
+ {{ form.non_field_errors() }} +
+
+ {{ form.task.errors }} + {{ form.task.label_tag() }} + {{ form.task|add_attr("x-model=action") }} +
+
{{ form.trigger_at.as_field_group() }}
+
+
+ {{ form.counters.as_field_group() }} +
+ {%- if form.DELETE -%} +
+ {{ form.DELETE.as_field_group() }} +
+ {%- else -%} + + {%- endif -%} + {%- for field in form.hidden_fields() -%} + {{ field }} + {%- endfor -%} +
+
+{% endmacro %} + + {% block content %} {% if object %}

{% trans name=object %}Edit product {{ name }}{% endtrans %}

+

{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}

+

{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}

{% else %}

{% trans %}Product creation{% endtrans %}

{% endif %} @@ -23,34 +64,20 @@

- {{ form.action_formset.management_form }} - {%- for action_form in form.action_formset.forms -%} -
- {{ action_form.non_field_errors() }} -
-
- {{ action_form.task.errors }} - {{ action_form.task.label_tag() }} - {{ action_form.task|add_attr("x-model=action") }} -
-
{{ action_form.trigger_at.as_field_group() }}
-
-
- {{ action_form.counters.as_field_group() }} -
- {%- if action_form.DELETE -%} -
- {{ action_form.DELETE.as_field_group() }} -
- {%- endif -%} - {%- for field in action_form.hidden_fields() -%} - {{ field }} +
+ {{ form.action_formset.management_form }} +
+ {%- for f in form.action_formset.forms -%} + {{ action_form(f) }} {%- endfor -%} -
- {%- if not loop.last -%} -
- {%- endif -%} - {%- endfor -%} -

+
+ + +
+

{% endblock %} \ No newline at end of file diff --git a/counter/templates/counter/product_list.jinja b/counter/templates/counter/product_list.jinja index 9644e88f..617aeaa5 100644 --- a/counter/templates/counter/product_list.jinja +++ b/counter/templates/counter/product_list.jinja @@ -89,7 +89,7 @@ :disabled="csvLoading" :aria-busy="csvLoading" > - {% trans %}Download as cvs{% endtrans %} + {% trans %}Download as CSV{% endtrans %}
diff --git a/counter/tests/test_counter_admin.py b/counter/tests/test_counter_admin.py new file mode 100644 index 00000000..91832786 --- /dev/null +++ b/counter/tests/test_counter_admin.py @@ -0,0 +1,181 @@ +from django.conf import settings +from django.contrib.auth.models import Permission +from django.test import TestCase +from django.urls import reverse +from model_bakery import baker + +from club.models import Membership +from core.baker_recipes import subscriber_user +from core.models import Group, User +from counter.baker_recipes import product_recipe +from counter.forms import CounterEditForm +from counter.models import Counter, CounterSellers + + +class TestEditCounterSellers(TestCase): + @classmethod + def setUpTestData(cls): + cls.counter = baker.make(Counter, type="BAR") + cls.products = product_recipe.make(_quantity=2, _bulk_create=True) + cls.counter.products.add(*cls.products) + users = subscriber_user.make(_quantity=6, _bulk_create=True) + cls.regular_barmen = users[:2] + cls.tmp_barmen = users[2:4] + cls.not_barmen = users[4:] + CounterSellers.objects.bulk_create( + [ + *baker.prepare( + CounterSellers, + counter=cls.counter, + user=iter(cls.regular_barmen), + is_regular=True, + _quantity=len(cls.regular_barmen), + ), + *baker.prepare( + CounterSellers, + counter=cls.counter, + user=iter(cls.tmp_barmen), + is_regular=False, + _quantity=len(cls.tmp_barmen), + ), + ] + ) + cls.operator = baker.make( + User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)] + ) + + def test_view_ok(self): + url = reverse("counter:admin", kwargs={"counter_id": self.counter.id}) + self.client.force_login(self.operator) + res = self.client.get(url) + assert res.status_code == 200 + res = self.client.post( + url, + data={ + "sellers_regular": [u.id for u in self.regular_barmen], + "sellers_temporary": [u.id for u in self.tmp_barmen], + "products": [p.id for p in self.products], + }, + ) + self.assertRedirects(res, url) + + def test_add_barmen(self): + form = CounterEditForm( + data={ + "sellers_regular": [*self.regular_barmen, self.not_barmen[0]], + "sellers_temporary": [*self.tmp_barmen, self.not_barmen[1]], + "products": self.products, + }, + instance=self.counter, + user=self.operator, + ) + assert form.is_valid() + form.save() + assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == { + *self.regular_barmen, + self.not_barmen[0], + } + assert set(self.counter.sellers.filter(countersellers__is_regular=False)) == { + *self.tmp_barmen, + self.not_barmen[1], + } + + def test_barman_change_status(self): + """Test when a barman goes from temporary to regular""" + form = CounterEditForm( + data={ + "sellers_regular": [*self.regular_barmen, self.tmp_barmen[0]], + "sellers_temporary": [*self.tmp_barmen[1:]], + "products": self.products, + }, + instance=self.counter, + user=self.operator, + ) + assert form.is_valid() + form.save() + assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == { + *self.regular_barmen, + self.tmp_barmen[0], + } + assert set( + self.counter.sellers.filter(countersellers__is_regular=False) + ) == set(self.tmp_barmen[1:]) + + def test_barman_duplicate(self): + """Test that a barman cannot be regular and temporary at the same time.""" + form = CounterEditForm( + data={ + "sellers_regular": [*self.regular_barmen, self.not_barmen[0]], + "sellers_temporary": [*self.tmp_barmen, self.not_barmen[0]], + "products": self.products, + }, + instance=self.counter, + user=self.operator, + ) + assert not form.is_valid() + assert form.errors == { + "__all__": [ + "Un utilisateur ne peut pas être un barman " + "régulier et temporaire en même temps, " + "mais les utilisateurs suivants ont été définis " + f"comme les deux : {self.not_barmen[0].get_display_name()}" + ], + } + assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == set( + self.regular_barmen + ) + assert set( + self.counter.sellers.filter(countersellers__is_regular=False) + ) == set(self.tmp_barmen) + + +class TestEditCounterProducts(TestCase): + @classmethod + def setUpTestData(cls): + cls.counter = baker.make(Counter) + cls.products = product_recipe.make(_quantity=5, _bulk_create=True) + cls.counter.products.add(*cls.products) + + def test_admin(self): + """Test that an admin can add and remove products""" + user = baker.make( + User, user_permissions=[Permission.objects.get(codename="change_counter")] + ) + new_product = product_recipe.make() + form = CounterEditForm( + data={"sellers": [], "products": [*self.products[1:], new_product]}, + user=user, + instance=self.counter, + ) + assert form.is_valid() + form.save() + assert set(self.counter.products.all()) == {*self.products[1:], new_product} + + def test_club_board_id(self): + """Test that people from counter club board can only add their own products.""" + club = self.counter.club + user = subscriber_user.make() + baker.make(Membership, user=user, club=club, end_date=None) + new_product = product_recipe.make(club=club) + form = CounterEditForm( + data={"sellers": [], "products": [*self.products[1:], new_product]}, + user=user, + instance=self.counter, + ) + assert form.is_valid() + form.save() + assert set(self.counter.products.all()) == {*self.products[1:], new_product} + + new_product = product_recipe.make() # product not owned by the club + form = CounterEditForm( + data={"sellers": [], "products": [*self.products[1:], new_product]}, + user=user, + instance=self.counter, + ) + assert not form.is_valid() + assert form.errors == { + "products": [ + "Sélectionnez un choix valide. " + f"{new_product.id} n\u2019en fait pas partie." + ], + } diff --git a/counter/tests/test_formula.py b/counter/tests/test_formula.py new file mode 100644 index 00000000..766b3870 --- /dev/null +++ b/counter/tests/test_formula.py @@ -0,0 +1,59 @@ +from django.test import TestCase + +from counter.baker_recipes import product_recipe +from counter.forms import ProductFormulaForm + + +class TestFormulaForm(TestCase): + @classmethod + def setUpTestData(cls): + cls.products = product_recipe.make( + selling_price=iter([1.5, 1, 1]), + special_selling_price=iter([1.4, 0.9, 0.9]), + _quantity=3, + _bulk_create=True, + ) + + def test_ok(self): + form = ProductFormulaForm( + data={ + "result": self.products[0].id, + "products": [self.products[1].id, self.products[2].id], + } + ) + assert form.is_valid() + formula = form.save() + assert formula.result == self.products[0] + assert set(formula.products.all()) == set(self.products[1:]) + + def test_price_invalid(self): + self.products[0].selling_price = 2.1 + self.products[0].save() + form = ProductFormulaForm( + data={ + "result": self.products[0].id, + "products": [self.products[1].id, self.products[2].id], + } + ) + assert not form.is_valid() + assert form.errors == { + "result": [ + "Le résultat ne peut pas être plus cher " + "que le total des autres produits." + ] + } + + def test_product_both_in_result_and_products(self): + form = ProductFormulaForm( + data={ + "result": self.products[0].id, + "products": [self.products[0].id, self.products[1].id], + } + ) + assert not form.is_valid() + assert form.errors == { + "__all__": [ + "Un même produit ne peut pas être à la fois " + "le résultat et un élément de la formule." + ] + } diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index d5d90c4c..a804fa42 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -15,8 +15,9 @@ from pytest_django.asserts import assertNumQueries, assertRedirects from club.models import Club from core.baker_recipes import board_user, subscriber_user from core.models import Group, User +from counter.baker_recipes import product_recipe from counter.forms import ProductForm -from counter.models import Product, ProductType +from counter.models import Product, ProductFormula, ProductType @pytest.mark.django_db @@ -93,6 +94,9 @@ class TestCreateProduct(TestCase): def setUpTestData(cls): cls.product_type = baker.make(ProductType) cls.club = baker.make(Club) + cls.counter_admin = baker.make( + User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)] + ) cls.data = { "name": "foo", "description": "bar", @@ -116,13 +120,36 @@ class TestCreateProduct(TestCase): assert instance.name == "foo" assert instance.selling_price == 1.0 - def test_view(self): - self.client.force_login( - baker.make( - User, - groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)], - ) + def test_form_with_product_from_formula(self): + """Test when the edited product is a result of a formula.""" + self.client.force_login(self.counter_admin) + products = product_recipe.make( + selling_price=iter([1.5, 1, 1]), + special_selling_price=iter([1.4, 0.9, 0.9]), + _quantity=3, + _bulk_create=True, ) + baker.make(ProductFormula, result=products[0], products=products[1:]) + + data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5} + form = ProductForm(data=data, instance=products[0]) + assert form.is_valid() + + # it shouldn't be possible to give a price higher than the formula's products + data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9} + form = ProductForm(data=data, instance=products[0]) + assert not form.is_valid() + assert form.errors == { + "selling_price": [ + "Assurez-vous que cette valeur est inférieure ou égale à 2.00." + ], + "special_selling_price": [ + "Assurez-vous que cette valeur est inférieure ou égale à 1.80." + ], + } + + def test_view(self): + self.client.force_login(self.counter_admin) url = reverse("counter:new_product") response = self.client.get(url) assert response.status_code == 200 diff --git a/counter/urls.py b/counter/urls.py index 67c7d950..9637ecd0 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -25,6 +25,10 @@ from counter.views.admin import ( CounterStatView, ProductCreateView, ProductEditView, + ProductFormulaCreateView, + ProductFormulaDeleteView, + ProductFormulaEditView, + ProductFormulaListView, ProductListView, ProductTypeCreateView, ProductTypeEditView, @@ -116,6 +120,24 @@ urlpatterns = [ ProductEditView.as_view(), name="product_edit", ), + path( + "admin/formula/", ProductFormulaListView.as_view(), name="product_formula_list" + ), + path( + "admin/formula/new/", + ProductFormulaCreateView.as_view(), + name="product_formula_create", + ), + path( + "admin/formula//edit", + ProductFormulaEditView.as_view(), + name="product_formula_edit", + ), + path( + "admin/formula//delete", + ProductFormulaDeleteView.as_view(), + name="product_formula_delete", + ), path( "admin/product-type/list/", ProductTypeListView.as_view(), diff --git a/counter/views/admin.py b/counter/views/admin.py index 24c112f5..9737bb16 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -16,6 +16,7 @@ from datetime import datetime, timedelta from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin +from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied from django.db import transaction from django.forms import CheckboxSelectMultiple @@ -34,11 +35,13 @@ from counter.forms import ( CloseCustomerAccountForm, CounterEditForm, ProductForm, + ProductFormulaForm, ReturnableProductForm, ) from counter.models import ( Counter, Product, + ProductFormula, ProductType, Refilling, ReturnableProduct, @@ -56,7 +59,9 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView): current_tab = "counters" -class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): +class CounterEditView( + CounterAdminTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView +): """Edit a counter's main informations (for the counter's manager).""" model = Counter @@ -64,11 +69,16 @@ class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): pk_url_kwarg = "counter_id" template_name = "core/edit.jinja" current_tab = "counters" + success_message = _("Counter update done") - def dispatch(self, request, *args, **kwargs): - obj = self.get_object() - self.edit_club.append(obj.club) - return super().dispatch(request, *args, **kwargs) + def test_func(self): + if self.request.user.has_perm("counter.change_counter"): + return True + obj = self.get_object(queryset=self.get_queryset().select_related("club")) + return obj.club.has_rights_in_club(self.request.user) + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"user": self.request.user} def get_success_url(self): return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id}) @@ -162,6 +172,62 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): current_tab = "products" +class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): + model = ProductFormula + queryset = ProductFormula.objects.select_related("result").prefetch_related( + "products" + ) + template_name = "counter/formula_list.jinja" + current_tab = "formulas" + permission_required = "counter.view_productformula" + + +class ProductFormulaCreateView( + CounterAdminTabsMixin, PermissionRequiredMixin, CreateView +): + model = ProductFormula + form_class = ProductFormulaForm + pk_url_kwarg = "formula_id" + template_name = "core/create.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.add_productformula" + + +class ProductFormulaEditView( + CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView +): + model = ProductFormula + form_class = ProductFormulaForm + pk_url_kwarg = "formula_id" + template_name = "core/edit.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.change_productformula" + + +class ProductFormulaDeleteView( + CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView +): + model = ProductFormula + pk_url_kwarg = "formula_id" + template_name = "core/delete_confirm.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.delete_productformula" + + def get_context_data(self, **kwargs): + obj_name = self.object.result.name + return super().get_context_data(**kwargs) | { + "object_name": _("%(formula)s (formula)") % {"formula": obj_name}, + "help_text": _( + "This action will only delete the formula, " + "but not the %(product)s product itself." + ) + % {"product": obj_name}, + } + + class ReturnableProductListView( CounterAdminTabsMixin, PermissionRequiredMixin, ListView ): diff --git a/counter/views/click.py b/counter/views/click.py index 02c0bdaa..29338d6e 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -12,6 +12,7 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # +from collections import defaultdict from django.core.exceptions import PermissionDenied from django.db import transaction @@ -31,6 +32,7 @@ from counter.forms import BasketForm, RefillForm from counter.models import ( Counter, Customer, + ProductFormula, ReturnableProduct, Selling, ) @@ -206,12 +208,13 @@ class CounterClick( """Add customer to the context.""" kwargs = super().get_context_data(**kwargs) kwargs["products"] = self.products - kwargs["categories"] = {} + kwargs["formulas"] = ProductFormula.objects.filter( + result__in=self.products + ).prefetch_related("products") + kwargs["categories"] = defaultdict(list) for product in kwargs["products"]: if product.product_type: - kwargs["categories"].setdefault(product.product_type, []).append( - product - ) + kwargs["categories"][product.product_type].append(product) kwargs["customer"] = self.customer kwargs["cancel_url"] = self.get_success_url() diff --git a/counter/views/mixins.py b/counter/views/mixins.py index c7fabdd6..2cce25b4 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -100,6 +100,11 @@ class CounterAdminTabsMixin(TabedViewMixin): "slug": "products", "name": _("Products"), }, + { + "url": reverse_lazy("counter:product_formula_list"), + "slug": "formulas", + "name": _("Formulas"), + }, { "url": reverse_lazy("counter:product_type_list"), "slug": "product_types", diff --git a/election/tests.py b/election/tests.py index 45ac3ea7..b4f78ff8 100644 --- a/election/tests.py +++ b/election/tests.py @@ -6,6 +6,8 @@ from django.test import Client, TestCase from django.urls import reverse from django.utils.timezone import now from model_bakery import baker +from model_bakery.recipe import Recipe +from pytest_django.asserts import assertRedirects from core.baker_recipes import subscriber_user from core.models import Group, User @@ -52,6 +54,102 @@ class TestElectionUpdateView(TestElection): assert response.status_code == 403 +class TestElectionForm(TestCase): + @classmethod + def setUpTestData(cls): + cls.election = baker.make(Election, end_date=now() + timedelta(days=1)) + cls.group = baker.make(Group) + cls.election.vote_groups.add(cls.group) + cls.election.edit_groups.add(cls.group) + lists = baker.make( + ElectionList, election=cls.election, _quantity=2, _bulk_create=True + ) + cls.roles = baker.make( + Role, election=cls.election, _quantity=2, _bulk_create=True + ) + users = baker.make(User, _quantity=4, _bulk_create=True) + recipe = Recipe(Candidature) + cls.cand = [ + recipe.prepare(role=cls.roles[0], user=users[0], election_list=lists[0]), + recipe.prepare(role=cls.roles[0], user=users[1], election_list=lists[1]), + recipe.prepare(role=cls.roles[1], user=users[2], election_list=lists[0]), + recipe.prepare(role=cls.roles[1], user=users[3], election_list=lists[1]), + ] + Candidature.objects.bulk_create(cls.cand) + cls.vote_url = reverse("election:vote", kwargs={"election_id": cls.election.id}) + cls.detail_url = reverse( + "election:detail", kwargs={"election_id": cls.election.id} + ) + + def test_election_good_form(self): + postes = (self.roles[0].title, self.roles[1].title) + votes = [ + {postes[0]: "", postes[1]: str(self.cand[2].id)}, + {postes[0]: "", postes[1]: ""}, + {postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[2].id)}, + {postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[3].id)}, + ] + voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True) + self.group.users.set(voters) + + for voter, vote in zip(voters, votes, strict=True): + assert self.election.can_vote(voter) + self.client.force_login(voter) + response = self.client.post(self.vote_url, data=vote) + assertRedirects(response, self.detail_url) + + assert set(self.election.voters.all()) == set(voters) + assert self.election.results == { + postes[0]: { + self.cand[0].user.username: {"percent": 50.0, "vote": 2}, + self.cand[1].user.username: {"percent": 0.0, "vote": 0}, + "blank vote": {"percent": 50.0, "vote": 2}, + "total vote": 4, + }, + postes[1]: { + self.cand[2].user.username: {"percent": 50.0, "vote": 2}, + self.cand[3].user.username: {"percent": 25.0, "vote": 1}, + "blank vote": {"percent": 25.0, "vote": 1}, + "total vote": 4, + }, + } + + def test_election_bad_form(self): + postes = (self.roles[0].title, self.roles[1].title) + + votes = [ + {postes[0]: "", postes[1]: str(self.cand[0].id)}, # wrong candidate + {postes[0]: ""}, + { + postes[0]: "0123456789", # unknow users + postes[1]: str(subscriber_user.make().id), # not a candidate + }, + {}, + ] + voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True) + self.group.users.set(voters) + + for voter, vote in zip(voters, votes, strict=True): + self.client.force_login(voter) + response = self.client.post(self.vote_url, data=vote) + assertRedirects(response, self.detail_url) + + assert self.election.results == { + postes[0]: { + self.cand[0].user.username: {"percent": 0.0, "vote": 0}, + self.cand[1].user.username: {"percent": 0.0, "vote": 0}, + "blank vote": {"percent": 100.0, "vote": 2}, + "total vote": 2, + }, + postes[1]: { + self.cand[2].user.username: {"percent": 0.0, "vote": 0}, + self.cand[3].user.username: {"percent": 0.0, "vote": 0}, + "blank vote": {"percent": 100.0, "vote": 2}, + "total vote": 2, + }, + } + + @pytest.mark.django_db def test_election_create_list_permission(client: Client): election = baker.make(Election, end_candidature=now() + timedelta(hours=1)) diff --git a/election/views.py b/election/views.py index addadb1a..63cd70d9 100644 --- a/election/views.py +++ b/election/views.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING from cryptography.utils import cached_property -from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import ( LoginRequiredMixin, @@ -115,16 +114,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView): def test_func(self): if not self.election.can_vote(self.request.user): return False - - groups = set(self.election.vote_groups.values_list("id", flat=True)) - if ( - settings.SITH_GROUP_SUBSCRIBERS_ID in groups - and self.request.user.is_subscribed - ): - # the subscriber group isn't truly attached to users, - # so it must be dealt with separately - return True - return self.request.user.groups.filter(id__in=groups).exists() + return self.election.vote_groups.filter( + id__in=self.request.user.all_groups + ).exists() def vote(self, election_data): with transaction.atomic(): @@ -238,15 +230,9 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): return False if self.request.user.has_perm("election.add_role"): return True - groups = set(self.election.edit_groups.values_list("id", flat=True)) - if ( - settings.SITH_GROUP_SUBSCRIBERS_ID in groups - and self.request.user.is_subscribed - ): - # the subscriber group isn't truly attached to users, - # so it must be dealt with separately - return True - return self.request.user.groups.filter(id__in=groups).exists() + return self.election.edit_groups.filter( + id__in=self.request.user.all_groups + ).exists() def get_initial(self): return {"election": self.election} @@ -279,14 +265,7 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView .union(self.election.edit_groups.values("id")) .values_list("id", flat=True) ) - if ( - settings.SITH_GROUP_SUBSCRIBERS_ID in groups - and self.request.user.is_subscribed - ): - # the subscriber group isn't truly attached to users, - # so it must be dealt with separately - return True - return self.request.user.groups.filter(id__in=groups).exists() + return not groups.isdisjoint(self.request.user.all_groups.keys()) def get_initial(self): return {"election": self.election} diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 50cff3ec..7ef7b81b 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: 2026-02-08 16:14+0100\n" +"POT-Creation-Date: 2026-03-10 10:28+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -388,7 +388,7 @@ msgstr "Montrer" #: club/templates/club/club_sellings.jinja #: counter/templates/counter/product_list.jinja -msgid "Download as cvs" +msgid "Download as CSV" msgstr "Télécharger en CSV" #: club/templates/club/club_sellings.jinja @@ -1566,7 +1566,7 @@ msgstr "Visiteur" msgid "ban type" msgstr "type de ban" -#: core/models.py +#: core/models.py counter/models.py msgid "created at" msgstr "créé le" @@ -2937,6 +2937,47 @@ msgstr "Cet UID est invalide" msgid "User not found" msgstr "Utilisateur non trouvé" +#: counter/forms.py +msgid "Regular barmen" +msgstr "Barmen réguliers" + +#: counter/forms.py +msgid "" +"Barmen having regular permanences or frequently giving a hand throughout the " +"semester." +msgstr "" +"Les barmen assurant des permanences régulières ou donnant régulièrement un " +"coup de main au cours du semestre." + +#: counter/forms.py +msgid "Temporary barmen" +msgstr "Barmen temporaires" + +#: counter/forms.py +msgid "" +"Barmen who will be there only for a limited period (e.g. for one evening)" +msgstr "" +"Les barmen qui seront là uniquement pour une durée limitée (par exemple, le " +"temps d'une soirée)" + +#: counter/forms.py +msgid "" +"If you want to add a product that is not owned by your club to this counter, " +"you should ask an admin." +msgstr "" +"Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à " +"votre club, vous devriez demander à un admin." + +#: counter/forms.py +#, python-format +msgid "" +"A user cannot be a regular and a temporary barman at the same time, but the " +"following users have been defined as both : %(users)s" +msgstr "" +"Un utilisateur ne peut pas être un barman régulier et temporaire en même " +"temps, mais les utilisateurs suivants ont été définis comme les deux : " +"%(users)s" + #: counter/forms.py msgid "Date and time of action" msgstr "Date et heure de l'action" @@ -2957,6 +2998,38 @@ msgstr "" "Décrivez le produit. Si c'est un click pour un évènement, donnez quelques " "détails dessus, comme la date (en incluant l'année)." +#: counter/forms.py +#, python-format +msgid "" +"This product is a formula. Its price cannot be greater than the price of the " +"products constituting it, which is %(price)s €" +msgstr "" +"Ce produit est une formule. Son prix ne peut pas être supérieur au prix des " +"produits qui la constituent, soit %(price)s €." + +#: counter/forms.py +#, python-format +msgid "" +"This product is a formula. Its special price cannot be greater than the " +"price of the products constituting it, which is %(price)s €" +msgstr "" +"Ce produit est une formule. Son prix spécial ne peut pas être supérieur au " +"prix des produits qui la constituent, soit %(price)s €." + +#: counter/forms.py +msgid "" +"The same product cannot be at the same time the result and a part of the " +"formula." +msgstr "" +"Un même produit ne peut pas être à la fois le résultat et un élément de la " +"formule." + +#: counter/forms.py +msgid "" +"The result cannot be more expensive than the total of the other products." +msgstr "" +"Le résultat ne peut pas être plus cher que le total des autres produits." + #: counter/forms.py msgid "Refound this account" msgstr "Rembourser ce compte" @@ -3109,6 +3182,10 @@ msgstr "groupe d'achat" msgid "archived" msgstr "archivé" +#: counter/models.py +msgid "updated at" +msgstr "mis à jour le" + #: counter/models.py msgid "product" msgstr "produit" @@ -3117,6 +3194,18 @@ msgstr "produit" msgid "products" msgstr "produits" +#: counter/models.py +msgid "The products that constitute this formula." +msgstr "Les produits qui constituent cette formule." + +#: counter/models.py +msgid "result product" +msgstr "produit résultat" + +#: counter/models.py +msgid "The product got with the formula." +msgstr "Le produit obtenu par la formule." + #: counter/models.py msgid "counter type" msgstr "type de comptoir" @@ -3137,6 +3226,10 @@ msgstr "vendeurs" msgid "token" msgstr "jeton" +#: counter/models.py +msgid "regular barman" +msgstr "barman régulier" + #: counter/models.py sith/settings.py msgid "Credit card" msgstr "Carte bancaire" @@ -3537,6 +3630,48 @@ msgstr "Nouveau eticket" msgid "There is no eticket in this website." msgstr "Il n'y a pas de eticket sur ce site web." +#: counter/templates/counter/formula_list.jinja +msgid "Product formulas" +msgstr "Formules de produits" + +#: counter/templates/counter/formula_list.jinja +msgid "" +"Formulas allow you to associate a group of products with a result product " +"(the formula itself)." +msgstr "" +"Les formules permettent d'associer un groupe de produits à un produit " +"résultat (la formule en elle-même)." + +#: counter/templates/counter/formula_list.jinja +msgid "" +"If the product of a formula is available on a counter, it will be " +"automatically applied if all the products that make it up are added to the " +"basket." +msgstr "" +"Si le produit d'une formule est disponible sur un comptoir, celle-ci sera " +"automatiquement appliquée si tous les produits qui la constituent sont " +"ajoutés au panier." + +#: counter/templates/counter/formula_list.jinja +msgid "" +"For example, if there is a formula that combines a \"Sandwich Formula\" " +"product with the \"Sandwich\" and \"Soft Drink\" products, then, if a person " +"orders a sandwich and a soft drink, the formula will be applied and the " +"basket will then contain a sandwich formula instead." +msgstr "" +"Par exemple s'il existe une formule associant un produit « Formule " +"sandwich » aux produits « Sandwich » et « Soft », alors, si une personne " +"commande un sandwich et un soft, la formule sera appliquée et le panier " +"contiendra alors une formule sandwich à la place." + +#: counter/templates/counter/formula_list.jinja +msgid "New formula" +msgstr "Nouvelle formule" + +#: counter/templates/counter/formula_list.jinja +msgid "instead of" +msgstr "au lieu de" + #: counter/templates/counter/fragments/create_student_card.jinja msgid "No student card registered." msgstr "Aucune carte étudiante enregistrée." @@ -3659,11 +3794,23 @@ msgstr "" "votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "aucune conséquence autre que le retrait de l'argent de votre compte." +#: counter/templates/counter/product_form.jinja +msgid "Remove this action" +msgstr "Retirer cette action" + #: counter/templates/counter/product_form.jinja #, python-format msgid "Edit product %(name)s" msgstr "Édition du produit %(name)s" +#: counter/templates/counter/product_form.jinja +msgid "Creation date" +msgstr "Date de création" + +#: counter/templates/counter/product_form.jinja +msgid "Last update" +msgstr "Dernière mise à jour" + #: counter/templates/counter/product_form.jinja msgid "Product creation" msgstr "Création de produit" @@ -3678,6 +3825,10 @@ msgstr "" "Les actions automatiques vous permettent de planifier des modifications du " "produit à l'avance." +#: counter/templates/counter/product_form.jinja +msgid "Add action" +msgstr "Ajouter une action" + #: counter/templates/counter/product_list.jinja msgid "Product list" msgstr "Liste des produits" @@ -3791,6 +3942,24 @@ msgstr "Temps" msgid "Top 100 barman %(counter_name)s (all semesters)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)" +#: counter/views/admin.py +msgid "Counter update done" +msgstr "Mise à jour du comptoir effectuée" + +#: counter/views/admin.py +#, python-format +msgid "%(formula)s (formula)" +msgstr "%(formula)s (formule)" + +#: counter/views/admin.py +#, python-format +msgid "" +"This action will only delete the formula, but not the %(product)s product " +"itself." +msgstr "" +"Cette action supprimera seulement la formule, mais pas le produit " +"%(product)s en lui-même." + #: counter/views/admin.py #, python-format msgid "returnable product : %(returnable)s -> %(returned)s" @@ -3876,6 +4045,10 @@ msgstr "Dernières opérations" msgid "Counter administration" msgstr "Administration des comptoirs" +#: counter/views/mixins.py +msgid "Formulas" +msgstr "Formules" + #: counter/views/mixins.py msgid "Product types" msgstr "Types de produit" @@ -3951,8 +4124,8 @@ msgid "" "inconvenience." msgstr "" "Les paiements par carte bancaire sont actuellement désactivés sur l'eboutic. " -"Vous pouvez cependant toujours recharger votre compte dans un des lieux de vie de l'AE. " -"Veuillez nous excuser pour le désagrément." +"Vous pouvez cependant toujours recharger votre compte dans un des lieux de " +"vie de l'AE. Veuillez nous excuser pour le désagrément." #: eboutic/templates/eboutic/eboutic_checkout.jinja msgid "" @@ -4121,8 +4294,8 @@ msgstr "Les candidatures sont fermées pour cette élection" #: election/templates/election/election_detail.jinja msgid "Candidate pictures won't display for privacy reasons." msgstr "" -"La photo du candidat ne s'affiche pas pour " -"des raisons de respect de la vie privée." +"La photo du candidat ne s'affiche pas pour des raisons de respect de la vie " +"privée." #: election/templates/election/election_detail.jinja msgid "Polls close " @@ -5121,8 +5294,6 @@ msgid "One day" msgstr "Un jour" #: sith/settings.py -#, fuzzy -#| msgid "GA staff member" msgid "GA staff member" msgstr "Membre staff GA" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 0dc0a145..9b598aee 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: 2025-08-23 15:30+0200\n" +"POT-Creation-Date: 2025-11-26 15:45+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -206,6 +206,10 @@ msgstr "capture.%s" msgid "Not enough money" msgstr "Pas assez d'argent" +#: counter/static/bundled/counter/counter-click-index.ts +msgid "Formula %(formula)s applied" +msgstr "Formule %(formula)s appliquée" + #: counter/static/bundled/counter/counter-click-index.ts msgid "You can't send an empty basket." msgstr "Vous ne pouvez pas envoyer un panier vide." @@ -262,3 +266,9 @@ msgstr "Il n'a pas été possible de modérer l'image" #: sas/static/bundled/sas/viewer-index.ts msgid "Couldn't delete picture" msgstr "Il n'a pas été possible de supprimer l'image" + +#: timetable/static/bundled/timetable/generator-index.ts +msgid "" +"Wrong timetable format. Make sure you copied if from your student folder." +msgstr "" +"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants." diff --git a/package-lock.json b/package-lock.json index 8ccb46e4..83c36196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,14 @@ "dependencies": { "@alpinejs/sort": "^3.15.8", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", - "@floating-ui/dom": "^1.7.5", + "@floating-ui/dom": "^1.7.6", "@fortawesome/fontawesome-free": "^7.2.0", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/icalendar": "^6.1.20", "@fullcalendar/list": "^6.1.20", - "@sentry/browser": "^10.38.0", - "@zip.js/zip.js": "^2.8.20", + "@sentry/browser": "^10.43.0", + "@zip.js/zip.js": "^2.8.23", "3d-force-graph": "^1.79.1", "alpinejs": "^3.15.8", "chart.js": "^4.5.1", @@ -28,21 +28,21 @@ "cytoscape-klay": "^3.1.4", "d3-force-3d": "^3.0.6", "easymde": "^2.20.0", - "glob": "^13.0.2", + "glob": "^13.0.6", "html2canvas": "^1.4.1", "htmx.org": "^2.0.8", "js-cookie": "^3.0.5", "lit-html": "^3.3.2", "native-file-system-adapter": "^3.0.1", - "three": "^0.182.0", + "three": "^0.183.2", "three-spritetext": "^1.10.0", - "tom-select": "^2.5.1" + "tom-select": "^2.5.2" }, "devDependencies": { "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.0", - "@biomejs/biome": "^2.3.14", - "@hey-api/openapi-ts": "^0.92.4", + "@biomejs/biome": "^2.4.6", + "@hey-api/openapi-ts": "^0.94.0", "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.11", "@types/cytoscape-cxtmenu": "^3.4.5", @@ -210,9 +210,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.7.tgz", + "integrity": "sha512-6Fqi8MtQ/PweQ9xvux65emkLQ83uB+qAVtfHkC9UodyHMIZdxNI01HjLCLUtybElp2KY2XNE0nOgyP1E1vXw9w==", "dev": true, "license": "MIT", "dependencies": { @@ -1590,9 +1590,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz", - "integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz", + "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -1606,20 +1606,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.14", - "@biomejs/cli-darwin-x64": "2.3.14", - "@biomejs/cli-linux-arm64": "2.3.14", - "@biomejs/cli-linux-arm64-musl": "2.3.14", - "@biomejs/cli-linux-x64": "2.3.14", - "@biomejs/cli-linux-x64-musl": "2.3.14", - "@biomejs/cli-win32-arm64": "2.3.14", - "@biomejs/cli-win32-x64": "2.3.14" + "@biomejs/cli-darwin-arm64": "2.4.6", + "@biomejs/cli-darwin-x64": "2.4.6", + "@biomejs/cli-linux-arm64": "2.4.6", + "@biomejs/cli-linux-arm64-musl": "2.4.6", + "@biomejs/cli-linux-x64": "2.4.6", + "@biomejs/cli-linux-x64-musl": "2.4.6", + "@biomejs/cli-win32-arm64": "2.4.6", + "@biomejs/cli-win32-x64": "2.4.6" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz", - "integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz", + "integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==", "cpu": [ "arm64" ], @@ -1634,9 +1634,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz", - "integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz", + "integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==", "cpu": [ "x64" ], @@ -1651,9 +1651,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz", - "integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz", + "integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==", "cpu": [ "arm64" ], @@ -1668,9 +1668,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz", - "integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz", + "integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==", "cpu": [ "arm64" ], @@ -1685,9 +1685,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz", - "integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz", + "integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==", "cpu": [ "x64" ], @@ -1702,9 +1702,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz", - "integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz", + "integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==", "cpu": [ "x64" ], @@ -1719,9 +1719,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz", - "integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz", + "integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==", "cpu": [ "arm64" ], @@ -1736,9 +1736,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz", - "integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz", + "integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==", "cpu": [ "x64" ], @@ -2195,28 +2195,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.4", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@fortawesome/fontawesome-free": { @@ -2266,9 +2266,9 @@ } }, "node_modules/@hey-api/codegen-core": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.0.tgz", - "integrity": "sha512-HglL4B4QwpzocE+c8qDU6XK8zMf8W8Pcv0RpFDYxHuYALWLTnpDUuEsglC7NQ4vC1maoXsBpMbmwpco0N4QviA==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.1.tgz", + "integrity": "sha512-X5qG+rr/BJvr+pEGcoW6l2azoZGrVuxsviEIhuf+3VwL9bk0atfubT65Xwo+4jDxXvjbhZvlwS0Ty3I7mLE2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -2288,9 +2288,9 @@ } }, "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.0.tgz", - "integrity": "sha512-3tQJ8N2egHXZjQWUeceoWrl88APWjo7gRrQ/L4HWJKnh6HowczCv7yNNFeSusPoWGV6HGdoFiCvq6UsLkrwKhg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.1.tgz", + "integrity": "sha512-7atnpUkT8TyUPHYPLk91j/GyaqMuwTEHanLOe50Dlx0EEvNuQqFD52Yjg8x4KU0UFL1mWlyhE+sUE/wAtQ1N2A==", "dev": true, "license": "MIT", "dependencies": { @@ -2306,15 +2306,15 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.92.4", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.92.4.tgz", - "integrity": "sha512-RA3wnL7Odr5xczuS3xpvnPClgJ/K8jivK3hvD8J0m5GBuvJFkZ1A1xp+6Ve1G0BV8p4LwxwgN1Qhb+4BFsLfMg==", + "version": "0.94.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.94.0.tgz", + "integrity": "sha512-dbg3GG+v7sg9/Ahb7yFzwzQIJwm151JAtsnh9KtFyqiN0rGkMGA3/VqogEUq1kJB9XWrlMQwigwzhiEQ33VCSg==", "dev": true, "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "0.7.0", - "@hey-api/json-schema-ref-parser": "1.3.0", - "@hey-api/shared": "0.2.0", + "@hey-api/codegen-core": "0.7.1", + "@hey-api/json-schema-ref-parser": "1.3.1", + "@hey-api/shared": "0.2.2", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "color-support": "1.1.3", @@ -2334,14 +2334,14 @@ } }, "node_modules/@hey-api/shared": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.0.tgz", - "integrity": "sha512-t7C+65ES12OqAE5k6DB/y5nDuTjydtqdxf/Qe4zflVn2AzGs7hO/7KjXvGXZYnpNVF7QISAcj0LEObASU9I53Q==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.2.tgz", + "integrity": "sha512-vMqCS+j7F9xpWoXC7TBbqZkaelwrdeuSB+s/3elu54V5iq++S59xhkSq5rOgDIpI1trpE59zZQa6dpyUxItOgw==", "dev": true, "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "0.7.0", - "@hey-api/json-schema-ref-parser": "1.3.0", + "@hey-api/codegen-core": "0.7.1", + "@hey-api/json-schema-ref-parser": "1.3.1", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", @@ -2381,27 +2381,6 @@ "typescript": ">=5.5.3" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2527,9 +2506,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -2541,9 +2520,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -2555,9 +2534,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -2569,9 +2548,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -2583,9 +2562,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -2597,9 +2576,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -2611,9 +2590,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -2625,9 +2604,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -2639,9 +2618,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -2653,9 +2632,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -2667,9 +2646,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -2681,9 +2674,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -2695,9 +2702,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -2709,9 +2716,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -2723,9 +2730,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -2737,9 +2744,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -2751,9 +2758,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -2764,10 +2771,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2779,9 +2800,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2793,9 +2814,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2807,9 +2828,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -2821,9 +2842,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2835,75 +2856,75 @@ ] }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.38.0.tgz", - "integrity": "sha512-UOJtYmdcxHCcV0NPfXFff/a95iXl/E0EhuQ1y0uE0BuZDMupWSF5t2BgC4HaE5Aw3RTjDF3XkSHWoIF6ohy7eA==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.43.0.tgz", + "integrity": "sha512-8zYTnzhAPvNkVH1Irs62wl0J/c+0QcJ62TonKnzpSFUUD3V5qz8YDZbjIDGfxy+1EB9fO0sxtddKCzwTHF/MbQ==", "license": "MIT", "dependencies": { - "@sentry/core": "10.38.0" + "@sentry/core": "10.43.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.38.0.tgz", - "integrity": "sha512-JXneg9zRftyfy1Fyfc39bBlF/Qd8g4UDublFFkVvdc1S6JQPlK+P6q22DKz3Pc8w3ySby+xlIq/eTu9Pzqi4KA==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.43.0.tgz", + "integrity": "sha512-YoXuwluP6eOcQxTeTtaWb090++MrLyWOVsUTejzUQQ6LFL13Jwt+bDPF1kvBugMq4a7OHw/UNKQfd6//rZMn2g==", "license": "MIT", "dependencies": { - "@sentry/core": "10.38.0" + "@sentry/core": "10.43.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.38.0.tgz", - "integrity": "sha512-YWIkL6/dnaiQyFiZXJ/nN+NXGv/15z45ia86bE/TMq01CubX/DUOilgsFz0pk2v/pg3tp/U2MskLO9Hz0cnqeg==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.43.0.tgz", + "integrity": "sha512-khCXlGrlH1IU7P5zCEAJFestMeH97zDVCekj8OsNNDtN/1BmCJ46k6Xi0EqAUzdJgrOLJeLdoYdgtiIjovZ8Sg==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.38.0", - "@sentry/core": "10.38.0" + "@sentry-internal/browser-utils": "10.43.0", + "@sentry/core": "10.43.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.38.0.tgz", - "integrity": "sha512-OXWM9jEqNYh4VTvrMu7v+z1anz+QKQ/fZXIZdsO7JTT2lGNZe58UUMeoq386M+Saxen8F9SUH7yTORy/8KI5qw==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.43.0.tgz", + "integrity": "sha512-ZIw1UNKOFXo1LbPCJPMAx9xv7D8TMZQusLDUgb6BsPQJj0igAuwd7KRGTkjjgnrwBp2O/sxcQFRhQhknWk7QPg==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.38.0", - "@sentry/core": "10.38.0" + "@sentry-internal/replay": "10.43.0", + "@sentry/core": "10.43.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/browser": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.38.0.tgz", - "integrity": "sha512-3phzp1YX4wcQr9mocGWKbjv0jwtuoDBv7+Y6Yfrys/kwyaL84mDLjjQhRf4gL5SX7JdYkhBp4WaiNlR0UC4kTA==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.43.0.tgz", + "integrity": "sha512-2V3I3sXi3SMeiZpKixd9ztokSgK27cmvsD9J5oyOyjhGLTW/6QKCwHbKnluMgQMXq20nixQk5zN4wRjRUma3sg==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.38.0", - "@sentry-internal/feedback": "10.38.0", - "@sentry-internal/replay": "10.38.0", - "@sentry-internal/replay-canvas": "10.38.0", - "@sentry/core": "10.38.0" + "@sentry-internal/browser-utils": "10.43.0", + "@sentry-internal/feedback": "10.43.0", + "@sentry-internal/replay": "10.43.0", + "@sentry-internal/replay-canvas": "10.43.0", + "@sentry/core": "10.43.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/core": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.38.0.tgz", - "integrity": "sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.43.0.tgz", + "integrity": "sha512-l0SszQAPiQGWl/ferw8GP3ALyHXiGiRKJaOvNmhGO+PrTQyZTZ6OYyPnGijAFRg58dE1V3RCH/zw5d2xSUIiNg==", "license": "MIT", "engines": { "node": ">=18" @@ -3008,9 +3029,9 @@ "license": "MIT" }, "node_modules/@zip.js/zip.js": { - "version": "2.8.20", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.20.tgz", - "integrity": "sha512-oJzVhK9gnSKD++WLG37QEgeTgm5W8XUYmNv0EhOxytSr85vXn9EMpOoKNTK3yWDLa55Z0MovKW/6RNeh9OUmnA==", + "version": "2.8.23", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.23.tgz", + "integrity": "sha512-RB+RLnxPJFPrGvQ9rgO+4JOcsob6lD32OcF0QE0yg24oeW9q8KnTTNlugcDaIveEcCbclobJcZP+fLQ++sH0bw==", "license": "BSD-3-Clause", "engines": { "bun": ">=0.7.0", @@ -3123,14 +3144,14 @@ "license": "Python-2.0" }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz", + "integrity": "sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw==", "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.7", "semver": "^6.3.1" }, "peerDependencies": { @@ -3138,13 +3159,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", - "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.1.tgz", + "integrity": "sha512-ENp89vM9Pw4kv/koBb5N2f9bDZsR0hpf3BdPMOg/pkS3pwO4dzNnQZVXtBbeyAadgm865DmQG2jMMLqmZXvuCw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.7", "core-js-compat": "^3.48.0" }, "peerDependencies": { @@ -3152,18 +3173,27 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.7.tgz", + "integrity": "sha512-OTYbUlSwXhNgr4g6efMZgsO8//jA61P7ZbRX3iTT53VON8l+WQS8IAUEVo4a4cWknrg2W8Cj4gQhRYNCJ8GkAA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6" + "@babel/helper-define-polyfill-provider": "^0.6.7" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -3174,13 +3204,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -3196,6 +3229,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3299,9 +3344,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -3798,9 +3843,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "dev": true, "license": "ISC" }, @@ -4020,17 +4065,17 @@ } }, "node_modules/glob": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", - "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.2", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4243,9 +4288,9 @@ } }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -4403,25 +4448,25 @@ } }, "node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -4492,9 +4537,9 @@ } }, "node_modules/ngraph.graph": { - "version": "20.1.1", - "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.1.tgz", - "integrity": "sha512-KNtZWYzYe7SMOuG3vvROznU+fkPmL5cGYFsWjqt+Ob1uF5xZz5EjomtsNOZEIwVuD37/zokeEqNK1ghY4/fhDg==", + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz", + "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==", "license": "BSD-3-Clause", "dependencies": { "ngraph.events": "^1.4.0" @@ -4541,9 +4586,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, @@ -4641,16 +4686,16 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4724,9 +4769,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -4890,9 +4935,9 @@ } }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4906,28 +4951,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -5136,9 +5184,9 @@ } }, "node_modules/three": { - "version": "0.182.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", - "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", "license": "MIT" }, "node_modules/three-forcegraph": { @@ -5253,9 +5301,9 @@ } }, "node_modules/tom-select": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.5.1.tgz", - "integrity": "sha512-63D5/Qf6bb6kLSgksEuas/60oawDcuUHrD90jZofeOpF6bkQFYriKrvtpJBQQ4xIA5dUGcjhBbk/yrlfOQsy3g==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.5.2.tgz", + "integrity": "sha512-VAlGj5MBWVLMJje2NwA3XSmxa7CUFpp1tdzFZ8wymCkcLeP0NwF4ARmSuUK4BWbmSN1fETlSazWkMIxEpP4GdQ==", "license": "Apache-2.0", "dependencies": { "@orchidjs/sifter": "^1.1.0", diff --git a/package.json b/package.json index 27867c9e..87a93831 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "devDependencies": { "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.0", - "@biomejs/biome": "^2.3.14", - "@hey-api/openapi-ts": "^0.92.4", + "@biomejs/biome": "^2.4.6", + "@hey-api/openapi-ts": "^0.94.0", "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.11", "@types/cytoscape-cxtmenu": "^3.4.5", @@ -43,14 +43,14 @@ "dependencies": { "@alpinejs/sort": "^3.15.8", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", - "@floating-ui/dom": "^1.7.5", + "@floating-ui/dom": "^1.7.6", "@fortawesome/fontawesome-free": "^7.2.0", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/icalendar": "^6.1.20", "@fullcalendar/list": "^6.1.20", - "@sentry/browser": "^10.38.0", - "@zip.js/zip.js": "^2.8.20", + "@sentry/browser": "^10.43.0", + "@zip.js/zip.js": "^2.8.23", "3d-force-graph": "^1.79.1", "alpinejs": "^3.15.8", "chart.js": "^4.5.1", @@ -60,14 +60,14 @@ "cytoscape-klay": "^3.1.4", "d3-force-3d": "^3.0.6", "easymde": "^2.20.0", - "glob": "^13.0.2", + "glob": "^13.0.6", "html2canvas": "^1.4.1", "htmx.org": "^2.0.8", "js-cookie": "^3.0.5", "lit-html": "^3.3.2", "native-file-system-adapter": "^3.0.1", - "three": "^0.182.0", + "three": "^0.183.2", "three-spritetext": "^1.10.0", - "tom-select": "^2.5.1" + "tom-select": "^2.5.2" } } diff --git a/pyproject.toml b/pyproject.toml index 8478917d..94273bf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ authors = [ license = { text = "GPL-3.0-only" } requires-python = "<4.0,>=3.12" dependencies = [ - "django>=5.2.11,<6.0.0", + "django>=5.2.12,<6.0.0", "django-ninja>=1.5.3,<6.0.0", "django-ninja-extra>=0.31.0", "Pillow>=12.1.1,<13.0.0", @@ -27,15 +27,15 @@ dependencies = [ "django-jinja<3.0.0,>=2.11.0", "cryptography>=46.0.5,<47.0.0", "django-phonenumber-field>=8.4.0,<9.0.0", - "phonenumbers>=9.0.23,<10.0.0", - "reportlab>=4.4.9,<5.0.0", + "phonenumbers>=9.0.25,<10.0.0", + "reportlab>=4.4.10,<5.0.0", "django-haystack<4.0.0,>=3.3.0", "xapian-haystack<4.0.0,>=3.1.0", "libsass<1.0.0,>=0.23.0", "django-ordered-model<4.0.0,>=3.7.4", "django-simple-captcha<1.0.0,>=0.6.3", "python-dateutil<3.0.0.0,>=2.9.0.post0", - "sentry-sdk>=2.52.0,<3.0.0", + "sentry-sdk>=2.54.0,<3.0.0", "jinja2<4.0.0,>=3.1.6", "django-countries>=8.2.0,<9.0.0", "dict2xml>=1.7.8,<2.0.0", @@ -51,7 +51,7 @@ dependencies = [ "psutil>=7.2.2,<8.0.0", "celery[redis]>=5.6.2,<7", "django-celery-results>=2.5.1", - "django-celery-beat>=2.7.0", + "django-celery-beat>=2.9.0", ] [project.urls] @@ -60,31 +60,31 @@ documentation = "https://sith-ae.readthedocs.io/" [dependency-groups] prod = [ - "psycopg[c]>=3.3.2,<4.0.0", + "psycopg[c]>=3.3.3,<4.0.0", ] dev = [ "django-debug-toolbar>=6.2.0,<7", - "ipython>=9.10.0,<10.0.0", + "ipython>=9.11.0,<10.0.0", "pre-commit>=4.5.1,<5.0.0", - "ruff>=0.15.0,<1.0.0", + "ruff>=0.15.5,<1.0.0", "djhtml>=3.0.10,<4.0.0", - "faker>=40.4.0,<41.0.0", + "faker>=40.8.0,<41.0.0", "rjsmin>=1.2.5,<2.0.0", ] tests = [ "freezegun>=1.5.5,<2.0.0", "pytest>=9.0.2,<10.0.0", "pytest-cov>=7.0.0,<8.0.0", - "pytest-django<5.0.0,>=4.10.0", - "model-bakery<2.0.0,>=1.23.2", + "pytest-django<5.0.0,>=4.12.0", + "model-bakery<2.0.0,>=1.23.3", "beautifulsoup4>=4.14.3,<5", "lxml>=6.0.2,<7", ] docs = [ "mkdocs<2.0.0,>=1.6.1", - "mkdocs-material>=9.7.1,<10.0.0", + "mkdocs-material>=9.7.5,<10.0.0", "mkdocstrings>=1.0.3,<2.0.0", - "mkdocstrings-python>=2.0.2,<3.0.0", + "mkdocstrings-python>=2.0.3,<3.0.0", "mkdocs-include-markdown-plugin>=7.2.1,<8.0.0", ] diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts index 8b07b0cf..3573970f 100644 --- a/sas/static/bundled/sas/viewer-index.ts +++ b/sas/static/bundled/sas/viewer-index.ts @@ -1,7 +1,6 @@ import type TomSelect from "tom-select"; import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts"; import { paginated } from "#core:utils/api.ts"; -import { exportToHtml } from "#core:utils/globals.ts"; import { History } from "#core:utils/history.ts"; import { type IdentifiedUserSchema, @@ -109,232 +108,225 @@ interface ViewerConfig { /** id of the first picture to load on the page */ firstPictureId: number; /** if the user is sas admin */ - userIsSasAdmin: boolean; + userCanModerate: boolean; } /** * Load user picture page with a nice download bar **/ -exportToHtml("loadViewer", (config: ViewerConfig) => { - document.addEventListener("alpine:init", () => { - Alpine.data("picture_viewer", () => ({ - /** - * All the pictures that can be displayed on this picture viewer - **/ - pictures: [] as PictureWithIdentifications[], - /** - * The currently displayed picture - * Default dummy data are pre-loaded to avoid javascript error - * when loading the page at the beginning - * @type PictureWithIdentifications - **/ - currentPicture: { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - is_moderated: true, - id: null as number, - name: "", - // biome-ignore lint/style/useNamingConvention: api is in snake_case - display_name: "", - // biome-ignore lint/style/useNamingConvention: api is in snake_case - compressed_url: "", - // biome-ignore lint/style/useNamingConvention: api is in snake_case - profile_url: "", - // biome-ignore lint/style/useNamingConvention: api is in snake_case - full_size_url: "", - owner: "", - date: new Date(), - identifications: [] as IdentifiedUserSchema[], - }, - /** - * The picture which will be displayed next if the user press the "next" button - **/ - nextPicture: null as PictureWithIdentifications, - /** - * The picture which will be displayed next if the user press the "previous" button - **/ - previousPicture: null as PictureWithIdentifications, - /** - * The select2 component used to identify users - **/ - selector: undefined as UserAjaxSelect, - /** - * Error message when a moderation operation fails - **/ - moderationError: "", - /** - * Method of pushing new url to the browser history - * Used by popstate event and always reset to it's default value when used - **/ - pushstate: History.Push, +document.addEventListener("alpine:init", () => { + Alpine.data("picture_viewer", (config: ViewerConfig) => ({ + /** + * All the pictures that can be displayed on this picture viewer + **/ + pictures: [] as PictureWithIdentifications[], + /** + * The currently displayed picture + * Default dummy data are pre-loaded to avoid javascript error + * when loading the page at the beginning + * @type PictureWithIdentifications + **/ + currentPicture: { + // biome-ignore lint/style/useNamingConvention: api is in snake_case + is_moderated: true, + id: null as number, + name: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + display_name: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + compressed_url: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + profile_url: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + full_size_url: "", + owner: "", + date: new Date(), + identifications: [] as IdentifiedUserSchema[], + }, + /** + * The picture which will be displayed next if the user press the "next" button + **/ + nextPicture: null as PictureWithIdentifications, + /** + * The picture which will be displayed next if the user press the "previous" button + **/ + previousPicture: null as PictureWithIdentifications, + /** + * The select2 component used to identify users + **/ + selector: undefined as UserAjaxSelect, + /** + * Error message when a moderation operation fails + **/ + moderationError: "", + /** + * Method of pushing new url to the browser history + * Used by popstate event and always reset to it's default value when used + **/ + pushstate: History.Push, - async init() { - this.pictures = ( - await paginated(picturesFetchPictures, { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - query: { album_id: config.albumId }, - } as PicturesFetchPicturesData) - ).map(PictureWithIdentifications.fromPicture); - this.selector = this.$refs.search; - this.selector.setFilter((users: UserProfileSchema[]) => { - const resp: UserProfileSchema[] = []; - const ids = [ - ...(this.currentPicture.identifications || []).map( - (i: IdentifiedUserSchema) => i.user.id, - ), - ]; - for (const user of users) { - if (!ids.includes(user.id)) { - resp.push(user); - } + async init() { + this.pictures = ( + await paginated(picturesFetchPictures, { + // biome-ignore lint/style/useNamingConvention: api is in snake_case + query: { album_id: config.albumId }, + } as PicturesFetchPicturesData) + ).map(PictureWithIdentifications.fromPicture); + this.selector = this.$refs.search; + this.selector.setFilter((users: UserProfileSchema[]) => { + const resp: UserProfileSchema[] = []; + const ids = [ + ...(this.currentPicture.identifications || []).map( + (i: IdentifiedUserSchema) => i.user.id, + ), + ]; + for (const user of users) { + if (!ids.includes(user.id)) { + resp.push(user); } - return resp; - }); - this.currentPicture = this.pictures.find( - (i: PictureSchema) => i.id === config.firstPictureId, - ); - this.$watch( - "currentPicture", - (current: PictureSchema, previous: PictureSchema) => { - if (current === previous) { - /* Avoid recursive updates */ - return; - } - this.updatePicture(); - }, - ); - window.addEventListener("popstate", async (event) => { - if (!event.state || event.state.sasPictureId === undefined) { + } + return resp; + }); + this.currentPicture = this.pictures.find( + (i: PictureSchema) => i.id === config.firstPictureId, + ); + this.$watch( + "currentPicture", + (current: PictureSchema, previous: PictureSchema) => { + if (current === previous) { + /* Avoid recursive updates */ return; } - this.pushstate = History.Replace; - this.currentPicture = this.pictures.find( - (i: PictureSchema) => - i.id === Number.parseInt(event.state.sasPictureId, 10), - ); - }); - this.pushstate = History.Replace; /* Avoid first url push */ - await this.updatePicture(); - }, - - /** - * Update the page. - * Called when the `currentPicture` property changes. - * - * The url is modified without reloading the page, - * and the previous picture, the next picture and - * the list of identified users are updated. - */ - async updatePicture(): Promise { - const updateArgs = { - data: { sasPictureId: this.currentPicture.id }, - unused: "", - url: this.currentPicture.sas_url, - }; - if (this.pushstate === History.Replace) { - window.history.replaceState( - updateArgs.data, - updateArgs.unused, - updateArgs.url, - ); - this.pushstate = History.Push; - } else { - window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url); - } - - this.moderationError = ""; - const index: number = this.pictures.indexOf(this.currentPicture); - this.previousPicture = this.pictures[index - 1] || null; - this.nextPicture = this.pictures[index + 1] || null; - this.$refs.mainPicture?.addEventListener("load", () => { - // once the current picture is loaded, - // start preloading the next and previous pictures - this.nextPicture?.preload(); - this.previousPicture?.preload(); - }); - if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) { - await Promise.all([ - this.currentPicture.loadIdentifications(), - this.currentPicture.loadModeration(), - ]); - } else { - await this.currentPicture.loadIdentifications(); - } - }, - - async moderatePicture() { - const res = await picturesModeratePicture({ - // biome-ignore lint/style/useNamingConvention: api is in snake_case - path: { picture_id: this.currentPicture.id }, - }); - if (res.error) { - this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`; + this.updatePicture(); + }, + ); + window.addEventListener("popstate", async (event) => { + if (!event.state || event.state.sasPictureId === undefined) { return; } - this.currentPicture.is_moderated = true; - this.currentPicture.asked_for_removal = false; - }, + this.pushstate = History.Replace; + this.currentPicture = this.pictures.find( + (i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10), + ); + }); + this.pushstate = History.Replace; /* Avoid first url push */ + await this.updatePicture(); + }, - async deletePicture() { - const res = await picturesDeletePicture({ + /** + * Update the page. + * Called when the `currentPicture` property changes. + * + * The url is modified without reloading the page, + * and the previous picture, the next picture and + * the list of identified users are updated. + */ + async updatePicture(): Promise { + const updateArgs = { + data: { sasPictureId: this.currentPicture.id }, + unused: "", + url: this.currentPicture.sas_url, + }; + if (this.pushstate === History.Replace) { + window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url); + this.pushstate = History.Push; + } else { + window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url); + } + + this.moderationError = ""; + const index: number = this.pictures.indexOf(this.currentPicture); + this.previousPicture = this.pictures[index - 1] || null; + this.nextPicture = this.pictures[index + 1] || null; + this.$refs.mainPicture?.addEventListener("load", () => { + // once the current picture is loaded, + // start preloading the next and previous pictures + this.nextPicture?.preload(); + this.previousPicture?.preload(); + }); + if (this.currentPicture.asked_for_removal && config.userCanModerate) { + await Promise.all([ + this.currentPicture.loadIdentifications(), + this.currentPicture.loadModeration(), + ]); + } else { + await this.currentPicture.loadIdentifications(); + } + }, + + async moderatePicture() { + const res = await picturesModeratePicture({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, + }); + if (res.error) { + this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`; + return; + } + this.currentPicture.is_moderated = true; + this.currentPicture.asked_for_removal = false; + }, + + async deletePicture() { + const res = await picturesDeletePicture({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, + }); + if (res.error) { + this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`; + return; + } + this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1); + if (this.pictures.length === 0) { + // The deleted picture was the only one in the list. + // As the album is now empty, go back to the parent page + document.location.href = config.albumUrl; + } + this.currentPicture = this.nextPicture || this.previousPicture; + }, + + /** + * Send the identification request and update the list of identified users. + */ + async submitIdentification(): Promise { + const widget: TomSelect = this.selector.widget; + await picturesIdentifyUsers({ + path: { // biome-ignore lint/style/useNamingConvention: api is in snake_case - path: { picture_id: this.currentPicture.id }, - }); - if (res.error) { - this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`; - return; - } - this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1); - if (this.pictures.length === 0) { - // The deleted picture was the only one in the list. - // As the album is now empty, go back to the parent page - document.location.href = config.albumUrl; - } - this.currentPicture = this.nextPicture || this.previousPicture; - }, + picture_id: this.currentPicture.id, + }, + body: widget.items.map((i: string) => Number.parseInt(i, 10)), + }); + // refresh the identified users list + await this.currentPicture.loadIdentifications({ forceReload: true }); - /** - * Send the identification request and update the list of identified users. - */ - async submitIdentification(): Promise { - const widget: TomSelect = this.selector.widget; - await picturesIdentifyUsers({ - path: { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - picture_id: this.currentPicture.id, - }, - body: widget.items.map((i: string) => Number.parseInt(i, 10)), - }); - // refresh the identified users list - await this.currentPicture.loadIdentifications({ forceReload: true }); + // Clear selection and cache of retrieved user so they can be filtered again + widget.clear(false); + widget.clearOptions(); + widget.setTextboxValue(""); + }, - // Clear selection and cache of retrieved user so they can be filtered again - widget.clear(false); - widget.clearOptions(); - widget.setTextboxValue(""); - }, + /** + * Check if an identification can be removed by the currently logged user + */ + canBeRemoved(identification: IdentifiedUserSchema): boolean { + return config.userCanModerate || identification.user.id === config.userId; + }, - /** - * Check if an identification can be removed by the currently logged user - */ - canBeRemoved(identification: IdentifiedUserSchema): boolean { - return config.userIsSasAdmin || identification.user.id === config.userId; - }, - - /** - * Untag a user from the current picture - */ - async removeIdentification(identification: IdentifiedUserSchema): Promise { - const res = await usersidentifiedDeleteRelation({ - // biome-ignore lint/style/useNamingConvention: api is in snake_case - path: { relation_id: identification.id }, - }); - if (!res.error && Array.isArray(this.currentPicture.identifications)) { - this.currentPicture.identifications = - this.currentPicture.identifications.filter( - (i: IdentifiedUserSchema) => i.id !== identification.id, - ); - } - }, - })); - }); + /** + * Untag a user from the current picture + */ + async removeIdentification(identification: IdentifiedUserSchema): Promise { + const res = await usersidentifiedDeleteRelation({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { relation_id: identification.id }, + }); + if (!res.error && Array.isArray(this.currentPicture.identifications)) { + this.currentPicture.identifications = + this.currentPicture.identifications.filter( + (i: IdentifiedUserSchema) => i.id !== identification.id, + ); + } + }, + })); }); diff --git a/sas/templates/sas/picture.jinja b/sas/templates/sas/picture.jinja index b68312d5..b37f232d 100644 --- a/sas/templates/sas/picture.jinja +++ b/sas/templates/sas/picture.jinja @@ -17,10 +17,8 @@ {% from "sas/macros.jinja" import print_path %} -{% set user_is_sas_admin = user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID) %} - {% block content %} -
+
SAS / {{ print_path(album) }} @@ -50,15 +48,13 @@ It will be hidden to other users until it has been moderated. {% endtrans %}

- {% if user_is_sas_admin %} + {% if user.has_perm("sas.moderate_sasfile") %}