From 2138783bde6bace5a5dd7035865dea8526b0b997 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 13 Feb 2026 12:49:48 +0100 Subject: [PATCH 01/34] add ban generation to populate_more --- core/management/commands/populate_more.py | 32 +++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) 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 = [] From e2fca3e6d272c987c78423cac16d3b36f7be373b Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 14 Feb 2026 15:22:18 +0100 Subject: [PATCH 02/34] fix: typo --- club/templates/club/club_sellings.jinja | 2 +- counter/templates/counter/product_list.jinja | 2 +- locale/fr/LC_MESSAGES/django.po | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) 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/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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 50cff3ec..5bc10a16 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-02-14 15:21+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 @@ -3951,8 +3951,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 +4121,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 " From 0eccb4a5b55eca6e85801d3a27498cccbc7a38c5 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 10 Feb 2026 18:18:12 +0100 Subject: [PATCH 03/34] Add `created_at` and `updated_at` to `Product` model --- counter/admin.py | 3 +- ...6_product_created_at_product_updated_at.py | 67 +++++++++++++++++++ counter/models.py | 2 + 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 counter/migrations/0036_product_created_at_product_updated_at.py 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/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/models.py b/counter/models.py index 6ceb9ea8..44101d42 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") From 43768171a1c39e9c44eb765b66a553469e8b2672 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 10 Feb 2026 18:30:05 +0100 Subject: [PATCH 04/34] show creation date on Product update page --- counter/templates/counter/product_form.jinja | 2 ++ locale/fr/LC_MESSAGES/django.po | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/counter/templates/counter/product_form.jinja b/counter/templates/counter/product_form.jinja index 00459b41..f3d3728c 100644 --- a/counter/templates/counter/product_form.jinja +++ b/counter/templates/counter/product_form.jinja @@ -3,6 +3,8 @@ {% 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 %} diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 5bc10a16..cbbf7a53 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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" @@ -3109,6 +3109,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" @@ -3664,6 +3668,14 @@ msgstr "" 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" From 7be1d1cc637aabaf0799b0eb05045aca46cb22f4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 23 Nov 2025 01:53:59 +0100 Subject: [PATCH 05/34] feat: ProductFormula model --- counter/migrations/0037_productformula.py | 43 +++++++++++++++++++++++ counter/models.py | 18 ++++++++++ 2 files changed, 61 insertions(+) create mode 100644 counter/migrations/0037_productformula.py 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/models.py b/counter/models.py index 44101d42..dbd1e7f0 100644 --- a/counter/models.py +++ b/counter/models.py @@ -456,6 +456,24 @@ 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, + on_delete=models.CASCADE, + verbose_name=_("result product"), + help_text=_("The product got with the formula."), + ) + + def __str__(self): + return self.result.name + + class CounterQuerySet(models.QuerySet): def annotate_has_barman(self, user: User) -> Self: """Annotate the queryset with the `user_is_barman` field. From b03346c7331d585a95542b3e927ea8d8e0628b0f Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 26 Nov 2025 13:22:43 +0100 Subject: [PATCH 06/34] product formulas management views --- counter/forms.py | 26 ++++++++++- counter/templates/counter/formula_list.jinja | 35 +++++++++++++++ counter/urls.py | 22 ++++++++++ counter/views/admin.py | 45 ++++++++++++++++++++ counter/views/mixins.py | 5 +++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 counter/templates/counter/formula_list.jinja diff --git a/counter/forms.py b/counter/forms.py index ed47232a..43f94720 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -5,6 +5,7 @@ from datetime import date, datetime, timezone from dateutil.relativedelta import relativedelta from django import forms +from django.core.exceptions import ValidationError from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.utils.timezone import now @@ -34,6 +35,7 @@ from counter.models import ( Eticket, InvoiceCall, Product, + ProductFormula, Refilling, ReturnableProduct, ScheduledProductAction, @@ -349,13 +351,33 @@ 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() + prices = [p.selling_price for p in cleaned_data["products"]] + selling_price = cleaned_data["result"].selling_price + if selling_price > sum(prices): + raise ValidationError( + _("This formula is more expensive than its constituant 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/templates/counter/formula_list.jinja b/counter/templates/counter/formula_list.jinja new file mode 100644 index 00000000..dab19302 --- /dev/null +++ b/counter/templates/counter/formula_list.jinja @@ -0,0 +1,35 @@ +{% extends "core/base.jinja" %} + +{% block title %} + {% trans %}Product formulas{% endtrans %} +{% endblock %} + +{% block additional_css %} + + +{% endblock %} + +{% block content %} +
+

{% trans %}Product formulas{% endtrans %}

+

+ + {% trans %}New formula{% endtrans %} + + +

+ + +
+{% endblock %} 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..ad48c435 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -34,11 +34,13 @@ from counter.forms import ( CloseCustomerAccountForm, CounterEditForm, ProductForm, + ProductFormulaForm, ReturnableProductForm, ) from counter.models import ( Counter, Product, + ProductFormula, ProductType, Refilling, ReturnableProduct, @@ -162,6 +164,49 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): current_tab = "products" +class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): + model = ProductFormula + queryset = ProductFormula.objects.select_related("result") + 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" + + class ReturnableProductListView( CounterAdminTabsMixin, PermissionRequiredMixin, ListView ): 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", From 4e73f103d8a7e0eddc54486f7aeebe8b387c224f Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 26 Nov 2025 15:53:16 +0100 Subject: [PATCH 07/34] automatically apply formulas on click --- .../bundled/counter/counter-click-index.ts | 49 ++++++++++++++----- counter/static/bundled/counter/types.d.ts | 6 +++ counter/static/counter/css/counter-click.scss | 4 +- counter/templates/counter/counter_click.jinja | 29 +++++++---- counter/views/click.py | 11 +++-- 5 files changed, 72 insertions(+), 27 deletions(-) diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 6d582b20..3e940bf8 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)), + ); + 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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 6f8bd246..c594b166 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -3589,10 +3589,44 @@ msgstr "Il n'y a pas de eticket sur ce site web." 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." From 8e4d36752205c91ba1c6c8d7788918651b12db85 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 6 Mar 2026 18:39:15 +0100 Subject: [PATCH 13/34] exclude archived products from product autocompletion. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dans tous les contextes avec un champ Ajax sur les produits, on a besoin uniquement des produits non-archivés. C'est plus cohérent d'exclure les produits archivés de la recherche. --- .../static/bundled/counter/components/ajax-select-index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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; } From 74bf462e90e5c93f2a71eb89c7e3afb00fc54915 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 7 Mar 2026 15:48:28 +0100 Subject: [PATCH 14/34] restrict products that non-admins can add to counter --- counter/forms.py | 19 +++++++-- counter/tests/test_counter_admin.py | 62 +++++++++++++++++++++++++++++ counter/views/admin.py | 14 ++++--- locale/fr/LC_MESSAGES/django.po | 10 ++++- 4 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 counter/tests/test_counter_admin.py diff --git a/counter/forms.py b/counter/forms.py index 6a1b83e4..1d9fa8a0 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -24,6 +24,7 @@ from core.views.forms import ( ) from core.views.widgets.ajax_select import ( AutoCompleteSelect, + AutoCompleteSelectMultiple, AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleUser, AutoCompleteSelectUser, @@ -170,11 +171,21 @@ class CounterEditForm(forms.ModelForm): class Meta: model = Counter fields = ["sellers", "products"] + widgets = {"sellers": AutoCompleteSelectMultipleUser} - widgets = { - "sellers": AutoCompleteSelectMultipleUser, - "products": AutoCompleteSelectMultipleProduct, - } + def __init__(self, *args, user: User, instance: Counter, **kwargs): + super().__init__(*args, instance=instance, **kwargs) + if user.has_perm("counter.change_counter"): + self.fields["products"].widget = AutoCompleteSelectMultipleProduct() + else: + 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." + ) class ScheduledProductActionForm(forms.ModelForm): diff --git a/counter/tests/test_counter_admin.py b/counter/tests/test_counter_admin.py new file mode 100644 index 00000000..eaade1c5 --- /dev/null +++ b/counter/tests/test_counter_admin.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import Permission +from django.test import TestCase +from model_bakery import baker + +from club.models import Membership +from core.baker_recipes import subscriber_user +from core.models import User +from counter.baker_recipes import product_recipe +from counter.forms import CounterEditForm +from counter.models import Counter + + +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/views/admin.py b/counter/views/admin.py index bd87150b..25990425 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -58,7 +58,7 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView): current_tab = "counters" -class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): +class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView): """Edit a counter's main informations (for the counter's manager).""" model = Counter @@ -67,10 +67,14 @@ class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): template_name = "core/edit.jinja" current_tab = "counters" - 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}) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index c594b166..8600040b 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-14 15:21+0100\n" +"POT-Creation-Date: 2026-03-07 15:47+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -2937,6 +2937,14 @@ msgstr "Cet UID est invalide" msgid "User not found" msgstr "Utilisateur non trouvé" +#: 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 msgid "Date and time of action" msgstr "Date et heure de l'action" From 5d3d44ec672f378cd9e84d645ea32212285c9e00 Mon Sep 17 00:00:00 2001 From: TitouanDor Date: Sun, 8 Mar 2026 15:09:46 +0100 Subject: [PATCH 15/34] modify price on discount --- sith/settings.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sith/settings.py b/sith/settings.py index 52d760b0..1339918d 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -551,27 +551,27 @@ SITH_SUBSCRIPTIONS = { # Discount subscriptions "un-semestre-reduction": { "name": _("One semester (-20%)"), - "price": 12, + "price": 16, "duration": 1, }, "deux-semestres-reduction": { "name": _("Two semesters (-20%)"), - "price": 22, + "price": 28, "duration": 2, }, "cursus-tronc-commun-reduction": { "name": _("Common core cursus (-20%)"), - "price": 36, + "price": 48, "duration": 4, }, "cursus-branche-reduction": { "name": _("Branch cursus (-20%)"), - "price": 36, + "price": 48, "duration": 6, }, "cursus-alternant-reduction": { "name": _("Alternating cursus (-20%)"), - "price": 24, + "price": 28, "duration": 6, }, # CA special offer From 52759764a1a27f1921e9ddee89cd3e25df46a4b7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 20 Feb 2026 18:46:37 +0100 Subject: [PATCH 16/34] feat: `User.all_groups` --- core/auth/mixins.py | 2 +- core/models.py | 29 +++++++++++++++++------------ core/tests/test_core.py | 8 ++++---- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/core/auth/mixins.py b/core/auth/mixins.py index 917200ed..b8d8ee10 100644 --- a/core/auth/mixins.py +++ b/core/auth/mixins.py @@ -308,5 +308,5 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin): 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 + g.id == self.club.board_group_id for g in self.request.user.all_groups ) diff --git a/core/models.py b/core/models.py index 27744775..a5ae84a3 100644 --- a/core/models.py +++ b/core/models.py @@ -356,23 +356,30 @@ 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 any(g.id == group_id for g in self.all_groups) @cached_property - def cached_groups(self) -> list[Group]: + def all_groups(self) -> list[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 list(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 any(g.id == root_id for g in self.all_groups) @cached_property def is_board_member(self) -> bool: @@ -1099,9 +1106,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) + groups_ids = [g.id for g in user.all_groups] return self.filter(view_groups__in=groups_ids) @@ -1376,7 +1381,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 any(g.id == self.page.owner_group_id for g 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/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) From 84ed180c1e49efdd7d856ff89c5b361f12039c40 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 21 Feb 2026 18:33:55 +0100 Subject: [PATCH 17/34] refactor sas moderation view permission --- sas/static/bundled/sas/viewer-index.ts | 417 ++++++++++++------------- sas/templates/sas/picture.jinja | 29 +- sas/tests/test_views.py | 16 +- sas/views.py | 29 +- 4 files changed, 239 insertions(+), 252 deletions(-) diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts index 8b07b0cf..69d29592 100644 --- a/sas/static/bundled/sas/viewer-index.ts +++ b/sas/static/bundled/sas/viewer-index.ts @@ -109,232 +109,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") %}