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 %}
+
+{% 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.action_formset.management_form }}
+ {% for action_form in form.action_formset.forms %}
+
+ {% 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 -%}
- {% 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)