From 83a4ac2a7e333c9f75b5d03b427ae714a1a587ff Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 14 Sep 2025 01:36:46 +0200 Subject: [PATCH 1/7] feat: automatic product archiving --- counter/forms.py | 87 +++++++++++++++++--- counter/models.py | 3 +- counter/tasks.py | 12 +++ counter/templates/counter/product_form.jinja | 31 +++++++ counter/views/admin.py | 4 +- locale/fr/LC_MESSAGES/django.po | 38 +++++++++ 6 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 counter/tasks.py create mode 100644 counter/templates/counter/product_form.jinja diff --git a/counter/forms.py b/counter/forms.py index c7df1d9d..4d299701 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,13 +1,19 @@ import math from django import forms +from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.translation import gettext_lazy as _ +from django_celery_beat.models import ClockedSchedule, PeriodicTask from phonenumber_field.widgets import RegionalPhoneNumberWidget from club.widgets.ajax_select import AutoCompleteSelectClub from core.models import User -from core.views.forms import NFCTextInput, SelectDate, SelectDateTime +from core.views.forms import ( + NFCTextInput, + SelectDate, + SelectDateTime, +) from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectMultipleGroup, @@ -158,6 +164,66 @@ class CounterEditForm(forms.ModelForm): } +class ProductArchiveForm(forms.Form): + """Form for automatic product archiving.""" + + enabled = forms.BooleanField( + label=_("Enabled"), + widget=forms.CheckboxInput(attrs={"class": "switch"}), + required=False, + ) + archive_at = forms.DateTimeField( + label=_("Date and time of archiving"), widget=SelectDateTime, required=False + ) + + def __init__(self, *args, product: Product, **kwargs): + self.product = product + self.instance = PeriodicTask.objects.filter( + task="counter.tasks.archive_product", args=f"[{product.id}]" + ).first() + super().__init__(*args, **kwargs) + if self.instance: + self.fields["enabled"].initial = self.instance.enabled + self.fields["archive_at"].initial = self.instance.clocked.clocked_time + + def clean(self): + cleaned_data = super().clean() + if cleaned_data["enabled"] is True and cleaned_data["archive_at"] is None: + raise ValidationError( + _( + "Automatic archiving cannot be enabled " + "without providing a archiving date." + ) + ) + + def save(self): + if not self.changed_data: + return + if not self.instance: + PeriodicTask.objects.create( + task="counter.tasks.archive_product", + args=f"[{self.product.id}]", + name=f"Archive product {self.product}", + clocked=ClockedSchedule.objects.create( + clocked_time=self.cleaned_data["archive_at"] + ), + enabled=self.cleaned_data["enabled"], + one_off=True, + ) + return + if ( + "archive_at" in self.changed_data + and self.cleaned_data["archive_at"] is None + ): + self.instance.delete() + elif "archive_at" in self.changed_data: + self.instance.clocked.clocked_time = self.cleaned_data["archive_at"] + self.instance.clocked.save() + self.instance.enabled = self.cleaned_data["enabled"] + self.instance.save() + return self.instance + + class ProductEditForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" @@ -199,22 +265,19 @@ class ProductEditForm(forms.ModelForm): queryset=Counter.objects.all(), ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, instance=None, **kwargs): + super().__init__(*args, instance=instance, **kwargs) if self.instance.id: self.fields["counters"].initial = self.instance.counters.all() + self.archive_form = ProductArchiveForm(*args, product=self.instance, **kwargs) + + def is_valid(self): + return super().is_valid() and self.archive_form.is_valid() def save(self, *args, **kwargs): ret = super().save(*args, **kwargs) - if self.fields["counters"].initial: - # Remove the product from all counter it was added to - # It will then only be added to selected counters - for counter in self.fields["counters"].initial: - counter.products.remove(self.instance) - counter.save() - for counter in self.cleaned_data["counters"]: - counter.products.add(self.instance) - counter.save() + self.instance.counters.set(self.cleaned_data["counters"]) + self.archive_form.save() return ret diff --git a/counter/models.py b/counter/models.py index 94f80d2c..a17fd309 100644 --- a/counter/models.py +++ b/counter/models.py @@ -445,7 +445,8 @@ class Product(models.Model): buying_groups = list(self.buying_groups.all()) if not buying_groups: return True - return any(user.is_in_group(pk=group.id) for group in buying_groups) + res = any(user.is_in_group(pk=group.id) for group in buying_groups) + return res @property def profit(self): diff --git a/counter/tasks.py b/counter/tasks.py new file mode 100644 index 00000000..34ea1a9c --- /dev/null +++ b/counter/tasks.py @@ -0,0 +1,12 @@ +# Create your tasks here + +from celery import shared_task + +from counter.models import Product + + +@shared_task +def archive_product(product_id: int): + product = Product.objects.get(id=product_id) + product.archived = True + product.save() diff --git a/counter/templates/counter/product_form.jinja b/counter/templates/counter/product_form.jinja new file mode 100644 index 00000000..f4980f51 --- /dev/null +++ b/counter/templates/counter/product_form.jinja @@ -0,0 +1,31 @@ +{% extends "core/base.jinja" %} + +{% block content %} + {% if object %} +

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

+ {% else %} +

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

+ {% endif %} +
+ {% csrf_token %} + {{ form.as_p() }} + +
+ +

{% trans %}Automatic archiving{% endtrans %}

+ +

+ + {%- trans trimmed -%} + Automatic archiving allows you to mark a product as archived + and remove it from all its counters at a specified time and date. + {%- endtrans -%} + +

+ +
+ {{ form.archive_form.as_p() }} +
+

+
+{% endblock %} \ No newline at end of file diff --git a/counter/views/admin.py b/counter/views/admin.py index ddd7a40e..7c1d4727 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -147,7 +147,7 @@ class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): model = Product form_class = ProductEditForm - template_name = "core/create.jinja" + template_name = "counter/product_form.jinja" current_tab = "products" @@ -157,7 +157,7 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): model = Product form_class = ProductEditForm pk_url_kwarg = "product_id" - template_name = "core/edit.jinja" + template_name = "counter/product_form.jinja" current_tab = "products" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 2ce8cde2..2bfc5fc4 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -556,6 +556,7 @@ msgstr "" #: core/templates/core/user_godfathers_tree.jinja #: core/templates/core/user_preferences.jinja #: counter/templates/counter/cash_register_summary.jinja +#: counter/templates/counter/product_form.jinja #: forum/templates/forum/reply.jinja #: subscription/templates/subscription/fragments/creation_form.jinja #: trombi/templates/trombi/comment.jinja @@ -2955,6 +2956,20 @@ msgstr "Cet UID est invalide" msgid "User not found" msgstr "Utilisateur non trouvé" +#: counter/forms.py +msgid "Enabled" +msgstr "Activé" + +#: counter/forms.py +msgid "Date and time of archiving" +msgstr "Date et heure de l'archivage" + +#: counter/forms.py +msgid "" +"Automatic archiving cannot be enabled without providing a archiving date." +msgstr "" +"L'archivage automatique ne peut pas activé sans fournir une date d'archivage." + #: counter/forms.py msgid "" "Describe the product. If it's an event's click, give some insights about it, " @@ -3607,6 +3622,29 @@ 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 +#, python-format +msgid "Edit product %(name)s" +msgstr "Édition du produit %(name)s" + +#: counter/templates/counter/product_form.jinja +#, fuzzy +#| msgid "Product state" +msgid "Product creation" +msgstr "Etat du produit" + +#: counter/templates/counter/product_form.jinja +msgid "Automatic archiving" +msgstr "Archivage automatique" + +#: counter/templates/counter/product_form.jinja +msgid "" +"Automatic archiving allows you to mark a product as archived and remove it " +"from all its counters at a specified time and date." +msgstr "" +"L'archivage automatique permet de marquer un produit comme archivé et de le " +"retirer de tous ses comptoirs à une heure et une date voulues." + #: counter/templates/counter/product_list.jinja msgid "Product list" msgstr "Liste des produits" From 5306001f6f398684adac74369aca67efb42b5e31 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 14 Sep 2025 21:47:51 +0200 Subject: [PATCH 2/7] `ScheduledProductAction` model to store tasks related to products --- .../migrations/0032_scheduledproductaction.py | 43 +++++++++++++++++++ counter/models.py | 28 ++++++++++++ counter/tasks.py | 11 ++++- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 counter/migrations/0032_scheduledproductaction.py diff --git a/counter/migrations/0032_scheduledproductaction.py b/counter/migrations/0032_scheduledproductaction.py new file mode 100644 index 00000000..40d00b9a --- /dev/null +++ b/counter/migrations/0032_scheduledproductaction.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.3 on 2025-09-14 11:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("counter", "0031_alter_counter_options"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.CreateModel( + name="ScheduledProductAction", + fields=[ + ( + "periodictask_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="django_celery_beat.periodictask", + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="scheduled_actions", + to="counter.product", + ), + ), + ], + options={ + "verbose_name": "Product scheduled action", + "ordering": ["-clocked"], + }, + bases=("django_celery_beat.periodictask",), + ), + ] diff --git a/counter/models.py b/counter/models.py index a17fd309..0b390c0f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -34,6 +34,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from django_celery_beat.models import PeriodicTask from django_countries.fields import CountryField from ordered_model.models import OrderedModel from phonenumber_field.modelfields import PhoneNumberField @@ -1358,3 +1359,30 @@ class ReturnableProductBalance(models.Model): f"return balance of {self.customer} " f"for {self.returnable.product_id} : {self.balance}" ) + + +def get_product_actions(): + return [ + ("counter.tasks.archive_product", _("Archiving")), + ("counter.tasks.change_counters", _("Counters change")), + ] + + +class ScheduledProductAction(PeriodicTask): + """Extension of celery-beat tasks dedicated to perform actions on Product.""" + + product = models.ForeignKey( + Product, related_name="scheduled_actions", on_delete=models.CASCADE + ) + + class Meta: + verbose_name = _("Product scheduled action") + ordering = ["-clocked"] + + def __init__(self, *args, **kwargs): + self._meta.get_field("task").choices = get_product_actions() + super().__init__(*args, **kwargs) + + def save(self, *args, **kwargs): + self.one_off = True # A product action should occur one time only + return super().save(*args, **kwargs) diff --git a/counter/tasks.py b/counter/tasks.py index 34ea1a9c..af5b5505 100644 --- a/counter/tasks.py +++ b/counter/tasks.py @@ -2,11 +2,18 @@ from celery import shared_task -from counter.models import Product +from counter.models import Counter, Product @shared_task -def archive_product(product_id: int): +def archive_product(*, product_id: int, **kwargs): product = Product.objects.get(id=product_id) product.archived = True product.save() + + +@shared_task +def change_counters(*, product_id: int, counters: list[int], **kwargs): + product = Product.objects.get(id=product_id) + counters = Counter.objects.filter(id__in=counters) + product.counters.set(counters) From 42b53a39f39ff75736e64282ee996ec1457115cf Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 14 Sep 2025 21:48:16 +0200 Subject: [PATCH 3/7] feat: automatic product counters edition --- core/static/core/forms.scss | 6 +- core/views/forms.py | 2 +- counter/forms.py | 149 ++++++++++++------ .../migrations/0032_scheduledproductaction.py | 5 +- counter/models.py | 1 - counter/templates/counter/product_form.jinja | 38 ++++- locale/fr/LC_MESSAGES/django.po | 44 +++--- 7 files changed, 160 insertions(+), 85 deletions(-) diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 3fb1685f..d761331c 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -154,10 +154,8 @@ form { margin-bottom: 1rem; } - .row { - label { - margin: unset; - } + .row > label { + margin: unset; } // ------------- LABEL diff --git a/core/views/forms.py b/core/views/forms.py index a8bbdfd6..fdfe61cf 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -115,7 +115,7 @@ class SelectUser(TextInput): def validate_future_timestamp(value: date | datetime): if value <= now(): - raise ValueError(_("Ensure this timestamp is set in the future")) + raise ValidationError(_("Ensure this timestamp is set in the future")) class FutureDateTimeField(forms.DateTimeField): diff --git a/counter/forms.py b/counter/forms.py index 4d299701..7bf523ce 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,15 +1,18 @@ +import json import math from django import forms -from django.core.exceptions import ValidationError from django.db.models import Q +from django.forms.formsets import DELETION_FIELD_NAME, BaseFormSet +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ -from django_celery_beat.models import ClockedSchedule, PeriodicTask +from django_celery_beat.models import ClockedSchedule from phonenumber_field.widgets import RegionalPhoneNumberWidget from club.widgets.ajax_select import AutoCompleteSelectClub from core.models import User from core.views.forms import ( + FutureDateTimeField, NFCTextInput, SelectDate, SelectDateTime, @@ -28,7 +31,9 @@ from counter.models import ( Product, Refilling, ReturnableProduct, + ScheduledProductAction, StudentCard, + get_product_actions, ) from counter.widgets.ajax_select import ( AutoCompleteSelectMultipleCounter, @@ -164,64 +169,108 @@ class CounterEditForm(forms.ModelForm): } -class ProductArchiveForm(forms.Form): - """Form for automatic product archiving.""" +class ScheduledProductActionForm(forms.Form): + """Form for automatic product archiving. - enabled = forms.BooleanField( - label=_("Enabled"), - widget=forms.CheckboxInput(attrs={"class": "switch"}), - required=False, + The `save` method will update or create tasks using celery-beat. + """ + + required_css_class = "required" + prefix = "product-action-form" + + task = forms.fields_for_model( + ScheduledProductAction, + ["task"], + widgets={"task": forms.RadioSelect(choices=get_product_actions)}, + labels={"task": _("Action")}, + help_texts={"task": ""}, + )["task"] + trigger_at = FutureDateTimeField( + label=_("Date and time of action"), widget=SelectDateTime ) - archive_at = forms.DateTimeField( - label=_("Date and time of archiving"), widget=SelectDateTime, required=False + counters = forms.ModelMultipleChoiceField( + label=_("New counters"), + help_text=_("The selected counters will replace the current ones"), + required=False, + widget=AutoCompleteSelectMultipleCounter, + queryset=Counter.objects.all(), ) def __init__(self, *args, product: Product, **kwargs): self.product = product - self.instance = PeriodicTask.objects.filter( - task="counter.tasks.archive_product", args=f"[{product.id}]" - ).first() super().__init__(*args, **kwargs) - if self.instance: - self.fields["enabled"].initial = self.instance.enabled - self.fields["archive_at"].initial = self.instance.clocked.clocked_time - - def clean(self): - cleaned_data = super().clean() - if cleaned_data["enabled"] is True and cleaned_data["archive_at"] is None: - raise ValidationError( - _( - "Automatic archiving cannot be enabled " - "without providing a archiving date." - ) - ) def save(self): if not self.changed_data: return - if not self.instance: - PeriodicTask.objects.create( - task="counter.tasks.archive_product", - args=f"[{self.product.id}]", - name=f"Archive product {self.product}", + task = self.cleaned_data["task"] + instance = ScheduledProductAction.objects.filter( + product=self.product, task=task + ).first() + if not instance: + instance = ScheduledProductAction( + product=self.product, clocked=ClockedSchedule.objects.create( - clocked_time=self.cleaned_data["archive_at"] + clocked_time=self.cleaned_data["trigger_at"] ), - enabled=self.cleaned_data["enabled"], - one_off=True, ) - return - if ( - "archive_at" in self.changed_data - and self.cleaned_data["archive_at"] is None - ): - self.instance.delete() - elif "archive_at" in self.changed_data: - self.instance.clocked.clocked_time = self.cleaned_data["archive_at"] - self.instance.clocked.save() - self.instance.enabled = self.cleaned_data["enabled"] - self.instance.save() - return self.instance + elif "trigger_at" in self.changed_data: + instance.clocked.clocked_time = self.cleaned_data["trigger_at"] + instance.clocked.save() + instance.task = task + task_kwargs = {"product_id": self.product.id} + if task == "counter.tasks.change_counters": + task_kwargs["counters"] = [c.id for c in self.cleaned_data["counters"]] + instance.kwargs = json.dumps(task_kwargs) + instance.name = f"{self.cleaned_data['task']} - {self.product}" + instance.enabled = True + instance.save() + return instance + + def delete(self): + instance = ScheduledProductAction.objects.get( + product=self.product, task=self.cleaned_data["task"] + ) + # if the clocked object linked to the task has no other task, + # delete it and let the task be deleted in cascade, + # else delete only the task. + if instance.clocked.periodictask_set.count() > 1: + return instance.clocked.delete() + return instance.delete() + + +class BaseScheduledProductActionFormSet(BaseFormSet): + def __init__(self, *args, product: Product, **kwargs): + initial = [ + { + "task": action.task, + "trigger_at": action.clocked.clocked_time, + "counters": json.loads(action.kwargs).get("counters"), + } + for action in product.scheduled_actions.filter( + enabled=True, clocked__clocked_time__gt=now() + ).order_by("clocked__clocked_time") + ] + kwargs["initial"] = initial + kwargs["form_kwargs"] = {"product": product} + super().__init__(*args, **kwargs) + + def save(self): + for form in self.forms: + if form.cleaned_data.get(DELETION_FIELD_NAME, False): + form.delete() + else: + form.save() + + +ScheduledProductActionFormSet = forms.formset_factory( + ScheduledProductActionForm, + formset=BaseScheduledProductActionFormSet, + absolute_max=None, + can_delete=True, + can_delete_extra=False, + extra=2, +) class ProductEditForm(forms.ModelForm): @@ -269,15 +318,17 @@ class ProductEditForm(forms.ModelForm): super().__init__(*args, instance=instance, **kwargs) if self.instance.id: self.fields["counters"].initial = self.instance.counters.all() - self.archive_form = ProductArchiveForm(*args, product=self.instance, **kwargs) + self.action_formset = ScheduledProductActionFormSet( + *args, product=self.instance, **kwargs + ) def is_valid(self): - return super().is_valid() and self.archive_form.is_valid() + return super().is_valid() and self.action_formset.is_valid() def save(self, *args, **kwargs): ret = super().save(*args, **kwargs) self.instance.counters.set(self.cleaned_data["counters"]) - self.archive_form.save() + self.action_formset.save() return ret diff --git a/counter/migrations/0032_scheduledproductaction.py b/counter/migrations/0032_scheduledproductaction.py index 40d00b9a..f312a095 100644 --- a/counter/migrations/0032_scheduledproductaction.py +++ b/counter/migrations/0032_scheduledproductaction.py @@ -34,10 +34,7 @@ class Migration(migrations.Migration): ), ), ], - options={ - "verbose_name": "Product scheduled action", - "ordering": ["-clocked"], - }, + options={"verbose_name": "Product scheduled action"}, bases=("django_celery_beat.periodictask",), ), ] diff --git a/counter/models.py b/counter/models.py index 0b390c0f..58fc8564 100644 --- a/counter/models.py +++ b/counter/models.py @@ -1377,7 +1377,6 @@ class ScheduledProductAction(PeriodicTask): class Meta: verbose_name = _("Product scheduled action") - ordering = ["-clocked"] def __init__(self, *args, **kwargs): self._meta.get_field("task").choices = get_product_actions() diff --git a/counter/templates/counter/product_form.jinja b/counter/templates/counter/product_form.jinja index f4980f51..53cb23f9 100644 --- a/counter/templates/counter/product_form.jinja +++ b/counter/templates/counter/product_form.jinja @@ -12,20 +12,44 @@
-

{% trans %}Automatic archiving{% endtrans %}

+

{% trans %}Automatic actions{% endtrans %}

-

+

{%- trans trimmed -%} - Automatic archiving allows you to mark a product as archived - and remove it from all its counters at a specified time and date. + Automatic actions allows to schedule product changes + ahead of time. {%- endtrans -%}

-
- {{ form.archive_form.as_p() }} -
+ {{ form.action_formset.management_form }} + {% for action_form in form.action_formset.forms %} +
+
+
+ {{ 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 %} +
+ {% if not loop.last %} +
+ {% endif %} + + {% endfor %}

{% endblock %} \ No newline at end of file diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 2bfc5fc4..8c151488 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -117,7 +117,7 @@ msgstr "S'abonner" msgid "Remove" msgstr "Retirer" -#: club/forms.py pedagogy/templates/pedagogy/moderation.jinja +#: club/forms.py counter/forms.py pedagogy/templates/pedagogy/moderation.jinja msgid "Action" msgstr "Action" @@ -2957,18 +2957,16 @@ msgid "User not found" msgstr "Utilisateur non trouvé" #: counter/forms.py -msgid "Enabled" -msgstr "Activé" +msgid "Date and time of action" +msgstr "Date et heure de l'action" #: counter/forms.py -msgid "Date and time of archiving" -msgstr "Date et heure de l'archivage" +msgid "New counters" +msgstr "Nouveaux comptoirs" #: counter/forms.py -msgid "" -"Automatic archiving cannot be enabled without providing a archiving date." -msgstr "" -"L'archivage automatique ne peut pas activé sans fournir une date d'archivage." +msgid "The selected counters will replace the current ones" +msgstr "Les comptoirs sélectionnés remplaceront les comptoirs actuels" #: counter/forms.py msgid "" @@ -3304,6 +3302,18 @@ msgid "The returnable product cannot be the same as the returned one" msgstr "" "Le produit consigné ne peut pas être le même que le produit de déconsigne" +#: counter/models.py +msgid "Archiving" +msgstr "Archivage" + +#: counter/models.py +msgid "Counters change" +msgstr "Changement des comptoirs" + +#: counter/models.py +msgid "Product scheduled action" +msgstr "Actions sur produit planifiées" + #: counter/templates/counter/activity.jinja #, python-format msgid "%(counter_name)s activity" @@ -3628,22 +3638,18 @@ msgid "Edit product %(name)s" msgstr "Édition du produit %(name)s" #: counter/templates/counter/product_form.jinja -#, fuzzy -#| msgid "Product state" msgid "Product creation" -msgstr "Etat du produit" +msgstr "Création de produit" #: counter/templates/counter/product_form.jinja -msgid "Automatic archiving" -msgstr "Archivage automatique" +msgid "Automatic actions" +msgstr "Actions automatiques" #: counter/templates/counter/product_form.jinja -msgid "" -"Automatic archiving allows you to mark a product as archived and remove it " -"from all its counters at a specified time and date." +msgid "Automatic actions allows to schedule product changes ahead of time." msgstr "" -"L'archivage automatique permet de marquer un produit comme archivé et de le " -"retirer de tous ses comptoirs à une heure et une date voulues." +"Les actions automatiques vous permettent de planifier des modifications du " +"produit à l'avance." #: counter/templates/counter/product_list.jinja msgid "Product list" From abd905c24d33b7b80f15d9de5965d9e7613a34fa Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 16 Sep 2025 07:32:19 +0200 Subject: [PATCH 4/7] write tests --- counter/forms.py | 2 +- counter/tests/test_auto_actions.py | 161 +++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 counter/tests/test_auto_actions.py diff --git a/counter/forms.py b/counter/forms.py index 7bf523ce..4c5a3b1d 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -176,7 +176,7 @@ class ScheduledProductActionForm(forms.Form): """ required_css_class = "required" - prefix = "product-action-form" + prefix = "scheduled" task = forms.fields_for_model( ScheduledProductAction, diff --git a/counter/tests/test_auto_actions.py b/counter/tests/test_auto_actions.py new file mode 100644 index 00000000..65975315 --- /dev/null +++ b/counter/tests/test_auto_actions.py @@ -0,0 +1,161 @@ +import json +from datetime import timedelta + +import pytest +from django.conf import settings +from django.test import Client +from django.urls import reverse +from django.utils.timezone import now +from django_celery_beat.models import ClockedSchedule +from model_bakery import baker +from pytest_django.asserts import assertNumQueries + +from core.models import Group, User +from counter.baker_recipes import counter_recipe, product_recipe +from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet +from counter.models import ScheduledProductAction + + +@pytest.mark.django_db +def test_edit_product(client: Client): + client.force_login( + baker.make( + User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)] + ) + ) + product = product_recipe.make() + url = reverse("counter:product_edit", kwargs={"product_id": product.id}) + res = client.get(url) + assert res.status_code == 200 + + res = client.post(url, data={}) + # This is actually a failure, but we just want to check that + # we don't have a 403 or a 500. + # The actual behaviour will be tested directly on the form. + assert res.status_code == 200 + + +@pytest.mark.django_db +class TestProductActionForm: + def test_single_form_archive(self): + product = product_recipe.make() + trigger_at = now() + timedelta(minutes=10) + form = ScheduledProductActionForm( + product=product, + data={ + "scheduled-task": "counter.tasks.archive_product", + "scheduled-trigger_at": trigger_at, + }, + ) + assert form.is_valid() + instance = form.save() + assert instance.clocked.clocked_time == trigger_at + assert instance.enabled is True + assert instance.one_off is True + assert instance.task == "counter.tasks.archive_product" + assert instance.kwargs == json.dumps({"product_id": product.id}) + + def test_single_form_change_counters(self): + product = product_recipe.make() + counter = counter_recipe.make() + trigger_at = now() + timedelta(minutes=10) + form = ScheduledProductActionForm( + product=product, + data={ + "scheduled-task": "counter.tasks.change_counters", + "scheduled-trigger_at": trigger_at, + "scheduled-counters": [counter.id], + }, + ) + assert form.is_valid() + instance = form.save() + instance.refresh_from_db() + assert instance.clocked.clocked_time == trigger_at + assert instance.enabled is True + assert instance.one_off is True + assert instance.task == "counter.tasks.change_counters" + assert instance.kwargs == json.dumps( + {"product_id": product.id, "counters": [counter.id]} + ) + + def test_task_reused_when_editing(self): + """Check that when product tasks are edited, no new entry is created in db""" + product = product_recipe.make() + old_clocked = now() + timedelta(minutes=10) + old_instance = baker.make( + ScheduledProductAction, + product=product, + clocked=baker.make(ClockedSchedule, clocked_time=old_clocked), + task="counter.tasks.archive_product", + ) + new_clocked = old_clocked + timedelta(minutes=2) + form = ScheduledProductActionForm( + product=product, + data={ + "scheduled-task": "counter.tasks.archive_product", + "scheduled-trigger_at": new_clocked, + }, + ) + assert form.is_valid() + new_instance = form.save() + assert new_instance.id == old_instance.id + assert new_instance.clocked.id == old_instance.clocked.id + assert new_instance.clocked.clocked_time == new_clocked + + def test_no_changed_data(self): + """Test that when no data changes, the save method does no db query""" + product = product_recipe.make() + trigger_at = now() + timedelta(minutes=2) + baker.make( + ScheduledProductAction, + product=product, + clocked=baker.make(ClockedSchedule, clocked_time=trigger_at), + task="counter.tasks.archive_product", + ) + form = ScheduledProductActionForm(product=product, data={}) + with assertNumQueries(0): + form.is_valid() + form.save() + + def test_delete(self): + product = product_recipe.make() + trigger_at = now() + timedelta(minutes=2) + task = baker.make( + ScheduledProductAction, + product=product, + clocked=baker.make(ClockedSchedule, clocked_time=trigger_at), + task="counter.tasks.archive_product", + ) + form = ScheduledProductActionForm( + product=product, + data={ + "scheduled-task": "counter.tasks.archive_product", + "scheduled-trigger_at": trigger_at, + }, + ) + assert form.is_valid() + form.delete() + assert not ScheduledProductAction.objects.filter(id=task.id).exists() + + +@pytest.mark.django_db +class TestProductActionFormSet: + def test_ok(self): + product = product_recipe.make() + counter = counter_recipe.make() + trigger_at = now() + timedelta(minutes=10) + formset = ScheduledProductActionFormSet( + product=product, + data={ + "form-TOTAL_FORMS": "2", + "form-INITIAL_FORMS": "0", + "form-0-task": "counter.tasks.archive_product", + "form-0-trigger_at": trigger_at, + "form-1-task": "counter.tasks.change_counters", + "form-1-trigger_at": trigger_at, + "form-1-counters": [counter.id], + }, + ) + assert formset.is_valid() + formset.save() + assert ScheduledProductAction.objects.filter(product=product).count() == 2 From 5b91fe214578246f922d1b4bd467c30bf81051fd Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 23 Sep 2025 15:51:02 +0200 Subject: [PATCH 5/7] use ModelFormSet instead of FormSet for scheduled actions --- counter/forms.py | 129 +++++++++---------- counter/models.py | 6 +- counter/templates/counter/product_form.jinja | 21 +-- counter/tests/test_auto_actions.py | 57 +------- 4 files changed, 78 insertions(+), 135 deletions(-) diff --git a/counter/forms.py b/counter/forms.py index 4c5a3b1d..3b1df846 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,9 +1,10 @@ import json import math +import uuid from django import forms from django.db.models import Q -from django.forms.formsets import DELETION_FIELD_NAME, BaseFormSet +from django.forms import BaseModelFormSet from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import ClockedSchedule @@ -12,7 +13,6 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget from club.widgets.ajax_select import AutoCompleteSelectClub from core.models import User from core.views.forms import ( - FutureDateTimeField, NFCTextInput, SelectDate, SelectDateTime, @@ -169,7 +169,7 @@ class CounterEditForm(forms.ModelForm): } -class ScheduledProductActionForm(forms.Form): +class ScheduledProductActionForm(forms.ModelForm): """Form for automatic product archiving. The `save` method will update or create tasks using celery-beat. @@ -178,14 +178,14 @@ class ScheduledProductActionForm(forms.Form): required_css_class = "required" prefix = "scheduled" - task = forms.fields_for_model( - ScheduledProductAction, - ["task"], - widgets={"task": forms.RadioSelect(choices=get_product_actions)}, - labels={"task": _("Action")}, - help_texts={"task": ""}, - )["task"] - trigger_at = FutureDateTimeField( + class Meta: + model = ScheduledProductAction + fields = ["task"] + widgets = {"task": forms.RadioSelect(choices=get_product_actions)} + labels = {"task": _("Action")} + help_texts = {"task": ""} + + trigger_at = forms.DateTimeField( label=_("Date and time of action"), widget=SelectDateTime ) counters = forms.ModelMultipleChoiceField( @@ -199,71 +199,58 @@ class ScheduledProductActionForm(forms.Form): def __init__(self, *args, product: Product, **kwargs): self.product = product super().__init__(*args, **kwargs) - - def save(self): - if not self.changed_data: - return - task = self.cleaned_data["task"] - instance = ScheduledProductAction.objects.filter( - product=self.product, task=task - ).first() - if not instance: - instance = ScheduledProductAction( - product=self.product, - clocked=ClockedSchedule.objects.create( - clocked_time=self.cleaned_data["trigger_at"] - ), + if not self.instance._state.adding: + self.fields["trigger_at"].initial = self.instance.clocked.clocked_time + self.fields["counters"].initial = json.loads(self.instance.kwargs).get( + "counters" ) - elif "trigger_at" in self.changed_data: - instance.clocked.clocked_time = self.cleaned_data["trigger_at"] - instance.clocked.save() - instance.task = task - task_kwargs = {"product_id": self.product.id} - if task == "counter.tasks.change_counters": - task_kwargs["counters"] = [c.id for c in self.cleaned_data["counters"]] - instance.kwargs = json.dumps(task_kwargs) - instance.name = f"{self.cleaned_data['task']} - {self.product}" - instance.enabled = True - instance.save() - return instance - def delete(self): - instance = ScheduledProductAction.objects.get( - product=self.product, task=self.cleaned_data["task"] - ) - # if the clocked object linked to the task has no other task, - # delete it and let the task be deleted in cascade, - # else delete only the task. - if instance.clocked.periodictask_set.count() > 1: - return instance.clocked.delete() - return instance.delete() - - -class BaseScheduledProductActionFormSet(BaseFormSet): - def __init__(self, *args, product: Product, **kwargs): - initial = [ - { - "task": action.task, - "trigger_at": action.clocked.clocked_time, - "counters": json.loads(action.kwargs).get("counters"), - } - for action in product.scheduled_actions.filter( - enabled=True, clocked__clocked_time__gt=now() - ).order_by("clocked__clocked_time") - ] - kwargs["initial"] = initial - kwargs["form_kwargs"] = {"product": product} - super().__init__(*args, **kwargs) - - def save(self): - for form in self.forms: - if form.cleaned_data.get(DELETION_FIELD_NAME, False): - form.delete() + def clean(self): + if not self.changed_data: + return super().clean() + if "trigger_at" in self.changed_data: + if not self.instance.clocked_id: + self.instance.clocked = ClockedSchedule( + clocked_time=self.cleaned_data["trigger_at"] + ) else: - form.save() + self.instance.clocked.clocked_time = self.cleaned_data["trigger_at"] + self.instance.clocked.save() + task_kwargs = {"product_id": self.product.id} + if ( + self.cleaned_data["task"] == "counter.tasks.change_counters" + and "counters" in self.changed_data + ): + task_kwargs["counters"] = [c.id for c in self.cleaned_data["counters"]] + self.instance.product = self.product + self.instance.kwargs = json.dumps(task_kwargs) + self.instance.name = ( + f"{self.cleaned_data['task']} - {self.product} - {uuid.uuid4()}" + ) + return super().clean() -ScheduledProductActionFormSet = forms.formset_factory( +class BaseScheduledProductActionFormSet(BaseModelFormSet): + def __init__(self, *args, product: Product, **kwargs): + queryset = ( + product.scheduled_actions.filter( + enabled=True, clocked__clocked_time__gt=now() + ) + .order_by("clocked__clocked_time") + .select_related("clocked") + ) + form_kwargs = {"product": product} + super().__init__(*args, queryset=queryset, form_kwargs=form_kwargs, **kwargs) + + def delete_existing(self, obj: ScheduledProductAction, commit: bool = True): # noqa FBT001 + clocked = obj.clocked + super().delete_existing(obj, commit=commit) + if commit: + clocked.delete() + + +ScheduledProductActionFormSet = forms.modelformset_factory( + ScheduledProductAction, ScheduledProductActionForm, formset=BaseScheduledProductActionFormSet, absolute_max=None, diff --git a/counter/models.py b/counter/models.py index 58fc8564..d7bc3b4e 100644 --- a/counter/models.py +++ b/counter/models.py @@ -481,7 +481,7 @@ class CounterQuerySet(models.QuerySet): return self.annotate(has_annotated_barman=Exists(subquery)) def annotate_is_open(self) -> Self: - """Annotate tue queryset with the `is_open` field. + """Annotate the queryset with the `is_open` field. For each counter, if `is_open=True`, then the counter is currently opened. Else the counter is closed. @@ -1382,6 +1382,6 @@ class ScheduledProductAction(PeriodicTask): self._meta.get_field("task").choices = get_product_actions() super().__init__(*args, **kwargs) - def save(self, *args, **kwargs): + def full_clean(self, *args, **kwargs): self.one_off = True # A product action should occur one time only - return super().save(*args, **kwargs) + return super().full_clean(*args, **kwargs) diff --git a/counter/templates/counter/product_form.jinja b/counter/templates/counter/product_form.jinja index 53cb23f9..afc9ba2a 100644 --- a/counter/templates/counter/product_form.jinja +++ b/counter/templates/counter/product_form.jinja @@ -24,32 +24,33 @@

{{ form.action_formset.management_form }} - {% for action_form in form.action_formset.forms %} + {%- 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.trigger_at.as_field_group() }}
{{ action_form.counters.as_field_group() }}
- {% if action_form.DELETE %} + {%- if action_form.DELETE -%}
{{ action_form.DELETE.as_field_group() }}
- {% endif %} + {%- endif -%} + {%- for field in action_form.hidden_fields() -%} + {{ field }} + {%- endfor -%}
- {% if not loop.last %} + {%- if not loop.last -%}
- {% endif %} - - {% endfor %} + {%- endif -%} + {%- endfor -%}

{% endblock %} \ No newline at end of file diff --git a/counter/tests/test_auto_actions.py b/counter/tests/test_auto_actions.py index 65975315..bc7f6937 100644 --- a/counter/tests/test_auto_actions.py +++ b/counter/tests/test_auto_actions.py @@ -8,7 +8,6 @@ from django.urls import reverse from django.utils.timezone import now from django_celery_beat.models import ClockedSchedule from model_bakery import baker -from pytest_django.asserts import assertNumQueries from core.models import Group, User from counter.baker_recipes import counter_recipe, product_recipe @@ -78,64 +77,20 @@ class TestProductActionForm: {"product_id": product.id, "counters": [counter.id]} ) - def test_task_reused_when_editing(self): - """Check that when product tasks are edited, no new entry is created in db""" - product = product_recipe.make() - old_clocked = now() + timedelta(minutes=10) - old_instance = baker.make( - ScheduledProductAction, - product=product, - clocked=baker.make(ClockedSchedule, clocked_time=old_clocked), - task="counter.tasks.archive_product", - ) - new_clocked = old_clocked + timedelta(minutes=2) - form = ScheduledProductActionForm( - product=product, - data={ - "scheduled-task": "counter.tasks.archive_product", - "scheduled-trigger_at": new_clocked, - }, - ) - assert form.is_valid() - new_instance = form.save() - assert new_instance.id == old_instance.id - assert new_instance.clocked.id == old_instance.clocked.id - assert new_instance.clocked.clocked_time == new_clocked - - def test_no_changed_data(self): - """Test that when no data changes, the save method does no db query""" - product = product_recipe.make() - trigger_at = now() + timedelta(minutes=2) - baker.make( - ScheduledProductAction, - product=product, - clocked=baker.make(ClockedSchedule, clocked_time=trigger_at), - task="counter.tasks.archive_product", - ) - form = ScheduledProductActionForm(product=product, data={}) - with assertNumQueries(0): - form.is_valid() - form.save() - def test_delete(self): product = product_recipe.make() - trigger_at = now() + timedelta(minutes=2) + clocked = baker.make(ClockedSchedule, clocked_time=now() + timedelta(minutes=2)) task = baker.make( ScheduledProductAction, product=product, - clocked=baker.make(ClockedSchedule, clocked_time=trigger_at), + one_off=True, + clocked=clocked, task="counter.tasks.archive_product", ) - form = ScheduledProductActionForm( - product=product, - data={ - "scheduled-task": "counter.tasks.archive_product", - "scheduled-trigger_at": trigger_at, - }, - ) - assert form.is_valid() - form.delete() + formset = ScheduledProductActionFormSet(product=product) + formset.delete_existing(task) assert not ScheduledProductAction.objects.filter(id=task.id).exists() + assert not ClockedSchedule.objects.filter(id=clocked.id).exists() @pytest.mark.django_db From f398c9901ca48cb9c464325ed65a8f569c98885a Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 29 Sep 2025 14:20:44 +0200 Subject: [PATCH 6/7] fix: 500 on product create view --- club/views.py | 17 ++---------- counter/forms.py | 21 ++++++++------ counter/tests/test_product.py | 52 +++++++++++++++++++++++++++++++++-- counter/views/admin.py | 6 ++-- eboutic/views.py | 4 +-- 5 files changed, 70 insertions(+), 30 deletions(-) diff --git a/club/views.py b/club/views.py index 0af2db9d..a14b71cd 100644 --- a/club/views.py +++ b/club/views.py @@ -31,11 +31,7 @@ from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.paginator import InvalidPage, Paginator from django.db.models import Q, Sum -from django.http import ( - Http404, - HttpResponseRedirect, - StreamingHttpResponse, -) +from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy from django.utils import timezone @@ -55,12 +51,7 @@ from club.forms import ( MailingForm, SellingsForm, ) -from club.models import ( - Club, - Mailing, - MailingSubscription, - Membership, -) +from club.models import Club, Mailing, MailingSubscription, Membership from com.models import Poster from com.views import ( PosterCreateBaseView, @@ -68,9 +59,7 @@ from com.views import ( PosterEditBaseView, PosterListBaseView, ) -from core.auth.mixins import ( - CanEditMixin, -) +from core.auth.mixins import CanEditMixin from core.models import PageRev from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin diff --git a/counter/forms.py b/counter/forms.py index 3b1df846..990d4afe 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -232,13 +232,16 @@ class ScheduledProductActionForm(forms.ModelForm): class BaseScheduledProductActionFormSet(BaseModelFormSet): def __init__(self, *args, product: Product, **kwargs): - queryset = ( - product.scheduled_actions.filter( - enabled=True, clocked__clocked_time__gt=now() + if product.id: + queryset = ( + product.scheduled_actions.filter( + enabled=True, clocked__clocked_time__gt=now() + ) + .order_by("clocked__clocked_time") + .select_related("clocked") ) - .order_by("clocked__clocked_time") - .select_related("clocked") - ) + else: + queryset = ScheduledProductAction.objects.none() form_kwargs = {"product": product} super().__init__(*args, queryset=queryset, form_kwargs=form_kwargs, **kwargs) @@ -260,7 +263,7 @@ ScheduledProductActionFormSet = forms.modelformset_factory( ) -class ProductEditForm(forms.ModelForm): +class ProductForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" @@ -367,7 +370,7 @@ class CloseCustomerAccountForm(forms.Form): ) -class ProductForm(forms.Form): +class BasketProductForm(forms.Form): quantity = forms.IntegerField(min_value=1, required=True) id = forms.IntegerField(min_value=0, required=True) @@ -472,5 +475,5 @@ class BaseBasketForm(forms.BaseFormSet): BasketForm = forms.formset_factory( - ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 + BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 ) diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index 8daab2b1..d5d90c4c 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -6,14 +6,16 @@ import pytest from django.conf import settings from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import Client +from django.test import Client, TestCase from django.urls import reverse from model_bakery import baker from PIL import Image -from pytest_django.asserts import assertNumQueries +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.forms import ProductForm from counter.models import Product, ProductType @@ -84,3 +86,49 @@ def test_fetch_product_nb_queries(client: Client): # - 1 for the actual request # - 1 to prefetch the related buying_groups client.get(reverse("api:search_products_detailed")) + + +class TestCreateProduct(TestCase): + @classmethod + def setUpTestData(cls): + cls.product_type = baker.make(ProductType) + cls.club = baker.make(Club) + cls.data = { + "name": "foo", + "description": "bar", + "product_type": cls.product_type.id, + "club": cls.club.id, + "code": "FOO", + "purchase_price": 1.0, + "selling_price": 1.0, + "special_selling_price": 1.0, + "limit_age": 0, + "form-TOTAL_FORMS": 0, + "form-INITIAL_FORMS": 0, + } + + def test_form(self): + form = ProductForm(data=self.data) + assert form.is_valid() + instance = form.save() + assert instance.club == self.club + assert instance.product_type == self.product_type + 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)], + ) + ) + url = reverse("counter:new_product") + response = self.client.get(url) + assert response.status_code == 200 + response = self.client.post(url, data=self.data) + assertRedirects(response, reverse("counter:product_list")) + product = Product.objects.last() + assert product.name == "foo" + assert product.club == self.club + assert product.product_type == self.product_type diff --git a/counter/views/admin.py b/counter/views/admin.py index 7c1d4727..38cf8878 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -32,7 +32,7 @@ from core.utils import get_semester_code, get_start_of_semester from counter.forms import ( CloseCustomerAccountForm, CounterEditForm, - ProductEditForm, + ProductForm, ReturnableProductForm, ) from counter.models import ( @@ -146,7 +146,7 @@ class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): """A create view for the admins.""" model = Product - form_class = ProductEditForm + form_class = ProductForm template_name = "counter/product_form.jinja" current_tab = "products" @@ -155,7 +155,7 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): """An edit view for the admins.""" model = Product - form_class = ProductEditForm + form_class = ProductForm pk_url_kwarg = "product_id" template_name = "counter/product_form.jinja" current_tab = "products" diff --git a/eboutic/views.py b/eboutic/views.py index bf232621..fa25df66 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -48,7 +48,7 @@ from django_countries.fields import Country from core.auth.mixins import CanViewMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin -from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm +from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm from counter.models import ( BillingInfo, Customer, @@ -78,7 +78,7 @@ class BaseEbouticBasketForm(BaseBasketForm): EbouticBasketForm = forms.formset_factory( - ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 + BasketProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 ) From dac52db434e6b51c42cca414c491c364fba46dde Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 29 Sep 2025 15:03:34 +0200 Subject: [PATCH 7/7] forbid past dates for product actions --- counter/forms.py | 5 +++-- counter/models.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/counter/forms.py b/counter/forms.py index 990d4afe..9b1a9ab6 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -13,6 +13,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget from club.widgets.ajax_select import AutoCompleteSelectClub from core.models import User from core.views.forms import ( + FutureDateTimeField, NFCTextInput, SelectDate, SelectDateTime, @@ -185,7 +186,7 @@ class ScheduledProductActionForm(forms.ModelForm): labels = {"task": _("Action")} help_texts = {"task": ""} - trigger_at = forms.DateTimeField( + trigger_at = FutureDateTimeField( label=_("Date and time of action"), widget=SelectDateTime ) counters = forms.ModelMultipleChoiceField( @@ -206,7 +207,7 @@ class ScheduledProductActionForm(forms.ModelForm): ) def clean(self): - if not self.changed_data: + if not self.changed_data or "trigger_at" in self.errors: return super().clean() if "trigger_at" in self.changed_data: if not self.instance.clocked_id: diff --git a/counter/models.py b/counter/models.py index d7bc3b4e..685071c3 100644 --- a/counter/models.py +++ b/counter/models.py @@ -1385,3 +1385,13 @@ class ScheduledProductAction(PeriodicTask): def full_clean(self, *args, **kwargs): self.one_off = True # A product action should occur one time only return super().full_clean(*args, **kwargs) + + def clean_clocked(self): + if not self.clocked: + raise ValidationError(_("Product actions must declare a clocked schedule.")) + + def validate_unique(self, *args, **kwargs): + # The checks done in PeriodicTask.validate_unique aren't + # adapted in the case of scheduled product action, + # so we skip it and execute directly Model.validate_unique + return super(PeriodicTask, self).validate_unique(*args, **kwargs)