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,11 +153,9 @@ form {
margin-bottom: 1rem;
}
.row {
label {
.row > label {
margin: unset;
}
}
// ------------- LABEL
label, legend {

View File

@@ -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):

View File

@@ -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

View File

@@ -12,20 +12,44 @@
<br />
<h3>{% trans %}Automatic archiving{% endtrans %}</h3>
<h3>{% trans %}Automatic actions{% endtrans %}</h3>
<p>
<p class="margin-bottom">
<em>
{%- 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 -%}
</em>
</p>
<fieldset x-data="{enabled: {{ form.archive_form.enabled.initial|tojson }}}">
{{ form.archive_form.as_p() }}
{{ form.action_formset.management_form }}
{% for action_form in form.action_formset.forms %}
<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>
</form>
{% endblock %}

View File

@@ -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 <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\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 "Acti"
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"