feat: automatic product counters edition

This commit is contained in:
imperosol
2025-09-14 21:48:16 +02:00
parent ea03786da8
commit d9f70a28ef
5 changed files with 159 additions and 81 deletions

View File

@@ -153,10 +153,8 @@ form {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.row { .row > label {
label { margin: unset;
margin: unset;
}
} }
// ------------- LABEL // ------------- LABEL

View File

@@ -115,7 +115,7 @@ class SelectUser(TextInput):
def validate_future_timestamp(value: date | datetime): def validate_future_timestamp(value: date | datetime):
if value <= now(): 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): class FutureDateTimeField(forms.DateTimeField):

View File

@@ -1,15 +1,17 @@
import json
import math import math
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Q 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.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 phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User from core.models import User
from core.views.forms import ( from core.views.forms import (
FutureDateTimeField,
NFCTextInput, NFCTextInput,
SelectDate, SelectDate,
SelectDateTime, SelectDateTime,
@@ -28,7 +30,9 @@ from counter.models import (
Product, Product,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
ScheduledProductAction,
StudentCard, StudentCard,
get_product_actions,
) )
from counter.widgets.ajax_select import ( from counter.widgets.ajax_select import (
AutoCompleteSelectMultipleCounter, AutoCompleteSelectMultipleCounter,
@@ -164,64 +168,108 @@ class CounterEditForm(forms.ModelForm):
} }
class ProductArchiveForm(forms.Form): class ScheduledProductActionForm(forms.Form):
"""Form for automatic product archiving.""" """Form for automatic product archiving.
enabled = forms.BooleanField( The `save` method will update or create tasks using celery-beat.
label=_("Enabled"), """
widget=forms.CheckboxInput(attrs={"class": "switch"}),
required=False, 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( counters = forms.ModelMultipleChoiceField(
label=_("Date and time of archiving"), widget=SelectDateTime, required=False 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): def __init__(self, *args, product: Product, **kwargs):
self.product = product self.product = product
self.instance = PeriodicTask.objects.filter(
task="counter.tasks.archive_product", args=f"[{product.id}]"
).first()
super().__init__(*args, **kwargs) 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): def save(self):
if not self.changed_data: if not self.changed_data:
return return
if not self.instance: task = self.cleaned_data["task"]
PeriodicTask.objects.create( instance = ScheduledProductAction.objects.filter(
task="counter.tasks.archive_product", product=self.product, task=task
args=f"[{self.product.id}]", ).first()
name=f"Archive product {self.product}", if not instance:
instance = ScheduledProductAction(
product=self.product,
clocked=ClockedSchedule.objects.create( 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 elif "trigger_at" in self.changed_data:
if ( instance.clocked.clocked_time = self.cleaned_data["trigger_at"]
"archive_at" in self.changed_data instance.clocked.save()
and self.cleaned_data["archive_at"] is None instance.task = task
): instance.kwargs = json.dumps(
self.instance.delete() {
elif "archive_at" in self.changed_data: "product_id": self.product.id,
self.instance.clocked.clocked_time = self.cleaned_data["archive_at"] "counters": [c.id for c in self.cleaned_data["counters"]],
self.instance.clocked.save() }
self.instance.enabled = self.cleaned_data["enabled"] )
self.instance.save() instance.name = f"{self.cleaned_data['task']} - {self.product}"
return self.instance 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): class ProductEditForm(forms.ModelForm):
@@ -269,15 +317,17 @@ class ProductEditForm(forms.ModelForm):
super().__init__(*args, instance=instance, **kwargs) super().__init__(*args, instance=instance, **kwargs)
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all() 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): 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): def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"]) self.instance.counters.set(self.cleaned_data["counters"])
self.archive_form.save() self.action_formset.save()
return ret return ret

View File

@@ -12,20 +12,44 @@
<br /> <br />
<h3>{% trans %}Automatic archiving{% endtrans %}</h3> <h3>{% trans %}Automatic actions{% endtrans %}</h3>
<p> <p class="margin-bottom">
<em> <em>
{%- trans trimmed -%} {%- trans trimmed -%}
Automatic archiving allows you to mark a product as archived Automatic actions allows to schedule product changes
and remove it from all its counters at a specified time and date. ahead of time.
{%- endtrans -%} {%- endtrans -%}
</em> </em>
</p> </p>
<fieldset x-data="{enabled: {{ form.archive_form.enabled.initial|tojson }}}"> {{ form.action_formset.management_form }}
{{ form.archive_form.as_p() }} {% for action_form in form.action_formset.forms %}
</fieldset> <fieldset x-data="{action: '{{ action_form.task.initial }}'}">
<div class="row gap-2x margin-bottom">
<div>
{{ action_form.task.errors }}
{{ action_form.task.label_tag() }}
{{ action_form.task|add_attr("x-model=action") }}
</div>
<div>
{{ action_form.trigger_at.as_field_group() }}
</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ action_form.counters.as_field_group() }}
</div>
{% if action_form.DELETE %}
<div class="row gap">
{{ action_form.DELETE.as_field_group() }}
</div>
{% endif %}
</fieldset>
{% if not loop.last %}
<hr class="margin-bottom">
{% endif %}
{% endfor %}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -117,7 +117,7 @@ msgstr "S'abonner"
msgid "Remove" msgid "Remove"
msgstr "Retirer" msgstr "Retirer"
#: club/forms.py pedagogy/templates/pedagogy/moderation.jinja #: club/forms.py counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action" msgid "Action"
msgstr "Action" msgstr "Action"
@@ -2894,18 +2894,16 @@ msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py #: counter/forms.py
msgid "Enabled" msgid "Date and time of action"
msgstr "Acti" msgstr "Date et heure de l'action"
#: counter/forms.py #: counter/forms.py
msgid "Date and time of archiving" msgid "New counters"
msgstr "Date et heure de l'archivage" msgstr "Nouveaux comptoirs"
#: counter/forms.py #: counter/forms.py
msgid "" msgid "The selected counters will replace the current ones"
"Automatic archiving cannot be enabled without providing a archiving date." msgstr "Les comptoirs sélectionnés remplaceront les comptoirs actuels"
msgstr ""
"L'archivage automatique ne peut pas activé sans fournir une date d'archivage."
#: counter/forms.py #: counter/forms.py
msgid "" msgid ""
@@ -3241,6 +3239,18 @@ msgid "The returnable product cannot be the same as the returned one"
msgstr "" msgstr ""
"Le produit consigné ne peut pas être le même que le produit de déconsigne" "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 #: counter/templates/counter/activity.jinja
#, python-format #, python-format
msgid "%(counter_name)s activity" msgid "%(counter_name)s activity"
@@ -3565,22 +3575,18 @@ msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s" msgstr "Édition du produit %(name)s"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
#, fuzzy
#| msgid "Product state"
msgid "Product creation" msgid "Product creation"
msgstr "Etat du produit" msgstr "Création de produit"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
msgid "Automatic archiving" msgid "Automatic actions"
msgstr "Archivage automatique" msgstr "Actions automatiques"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
msgid "" msgid "Automatic actions allows to schedule product changes ahead of time."
"Automatic archiving allows you to mark a product as archived and remove it "
"from all its counters at a specified time and date."
msgstr "" msgstr ""
"L'archivage automatique permet de marquer un produit comme archivé et de le " "Les actions automatiques vous permettent de planifier des modifications du "
"retirer de tous ses comptoirs à une heure et une date voulues." "produit à l'avance."
#: counter/templates/counter/product_list.jinja #: counter/templates/counter/product_list.jinja
msgid "Product list" msgid "Product list"