diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 9982e77f..cc60067a 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -153,10 +153,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..ac6bd22a 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,15 +1,17 @@ +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.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 +30,9 @@ from counter.models import ( Product, Refilling, ReturnableProduct, + ScheduledProductAction, StudentCard, + get_product_actions, ) from counter.widgets.ajax_select import ( AutoCompleteSelectMultipleCounter, @@ -164,64 +168,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 + instance.kwargs = json.dumps( + { + "product_id": self.product.id, + "counters": [c.id for c in self.cleaned_data["counters"]], + } + ) + 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) + ] + 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 +317,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/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 443e45db..7d47ab59 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: 2025-09-14 01:35+0200\n" +"POT-Creation-Date: 2025-09-14 17:09+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -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" @@ -2894,18 +2894,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 "" @@ -3241,6 +3239,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" @@ -3565,22 +3575,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"