Compare commits

..

3 Commits

Author SHA1 Message Date
imperosol
d9f70a28ef feat: automatic product counters edition 2025-09-14 21:48:16 +02:00
imperosol
ea03786da8 ScheduledProductAction model to store tasks related to products 2025-09-14 21:47:51 +02:00
imperosol
5274f1f0f0 feat: automatic product archiving 2025-09-14 01:36:46 +02:00
10 changed files with 327 additions and 29 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,13 +1,21 @@
import json
import math import math
from django import forms from django import forms
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
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 NFCTextInput, SelectDate, SelectDateTime from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
SelectDate,
SelectDateTime,
)
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
@@ -22,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,
@@ -158,6 +168,110 @@ class CounterEditForm(forms.ModelForm):
} }
class ScheduledProductActionForm(forms.Form):
"""Form for automatic product archiving.
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
)
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
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"]
),
)
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): class ProductEditForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -199,22 +313,21 @@ class ProductEditForm(forms.ModelForm):
queryset=Counter.objects.all(), queryset=Counter.objects.all(),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, **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.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs
)
def is_valid(self):
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)
if self.fields["counters"].initial: self.instance.counters.set(self.cleaned_data["counters"])
# Remove the product from all counter it was added to self.action_formset.save()
# 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()
return ret return ret

View File

@@ -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",),
),
]

View File

@@ -34,6 +34,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import PeriodicTask
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
@@ -445,7 +446,8 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all()) buying_groups = list(self.buying_groups.all())
if not buying_groups: if not buying_groups:
return True 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 @property
def profit(self): def profit(self):
@@ -1362,3 +1364,30 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} " f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}" 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)

20
counter/tasks.py Normal file
View File

@@ -0,0 +1,20 @@
# Create your tasks here
from celery import shared_task
from counter.models import Counter, Product
@shared_task
def archive_product(*, product_id: int):
product = Product.objects.get(id=product_id)
product.archived = True
product.save()
product.counters.clear()
@shared_task
def change_counters(*, product_id: int, counters: list[int]):
product = Product.objects.get(id=product_id)
counters = Counter.objects.filter(id__in=counters)
product.counters.set(counters)

View File

@@ -0,0 +1,55 @@
{% extends "core/base.jinja" %}
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
{% else %}
<h2>{% trans %}Product creation{% endtrans %}</h2>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p() }}
<br />
<h3>{% trans %}Automatic actions{% endtrans %}</h3>
<p class="margin-bottom">
<em>
{%- trans trimmed -%}
Automatic actions allows to schedule product changes
ahead of time.
{%- endtrans -%}
</em>
</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

@@ -147,7 +147,7 @@ class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
model = Product model = Product
form_class = ProductEditForm form_class = ProductEditForm
template_name = "core/create.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"
@@ -157,7 +157,7 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
model = Product model = Product
form_class = ProductEditForm form_class = ProductEditForm
pk_url_kwarg = "product_id" pk_url_kwarg = "product_id"
template_name = "core/edit.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"

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-01 18:18+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"
@@ -561,6 +561,7 @@ msgstr ""
#: core/templates/core/user_godfathers_tree.jinja #: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja #: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja #: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja #: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja #: subscription/templates/subscription/fragments/creation_form.jinja
#: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/comment.jinja
@@ -1713,8 +1714,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises " "AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities." "student life at UTBM and manages its student facilities."
msgstr "" msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de " "L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." "Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
@@ -2157,10 +2158,6 @@ msgstr ""
msgid "Page history" msgid "Page history"
msgstr "Historique de la page" msgstr "Historique de la page"
#: core/templates/core/page_list.jinja
msgid "There is no page in this website."
msgstr "Il n'y a pas de page sur ce site web."
#: core/templates/core/page_prop.jinja #: core/templates/core/page_prop.jinja
msgid "Page properties" msgid "Page properties"
msgstr "Propriétés de la page" msgstr "Propriétés de la page"
@@ -2896,6 +2893,18 @@ msgstr "Cet UID est invalide"
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Date and time of action"
msgstr "Date et heure de l'action"
#: counter/forms.py
msgid "New counters"
msgstr "Nouveaux comptoirs"
#: counter/forms.py
msgid "The selected counters will replace the current ones"
msgstr "Les comptoirs sélectionnés remplaceront les comptoirs actuels"
#: counter/forms.py #: counter/forms.py
msgid "" msgid ""
"Describe the product. If it's an event's click, give some insights about it, " "Describe the product. If it's an event's click, give some insights about it, "
@@ -3230,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"
@@ -3548,6 +3569,25 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "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." "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
msgid "Product creation"
msgstr "Création de produit"
#: counter/templates/counter/product_form.jinja
msgid "Automatic actions"
msgstr "Actions automatiques"
#: counter/templates/counter/product_form.jinja
msgid "Automatic actions allows to schedule product changes ahead of time."
msgstr ""
"Les actions automatiques vous permettent de planifier des modifications du "
"produit à l'avance."
#: counter/templates/counter/product_list.jinja #: counter/templates/counter/product_list.jinja
msgid "Product list" msgid "Product list"
msgstr "Liste des produits" msgstr "Liste des produits"

View File

@@ -74,7 +74,7 @@ dev = [
tests = [ tests = [
"freezegun<2.0.0,>=1.5.1", "freezegun<2.0.0,>=1.5.1",
"pytest<9.0.0,>=8.3.5", "pytest<9.0.0,>=8.3.5",
"pytest-cov>=6.0.0,<8.0.0", "pytest-cov<7.0.0,>=6.0.0",
"pytest-django<5.0.0,>=4.10.0", "pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.20.4", "model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.13.3,<5", "beautifulsoup4>=4.13.3,<5",