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;
}
.row {
label {
margin: unset;
}
.row > label {
margin: unset;
}
// ------------- LABEL

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,13 +1,21 @@
import json
import math
from django import forms
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
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 (
FutureDateTimeField,
NFCTextInput,
SelectDate,
SelectDateTime,
)
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
@@ -22,7 +30,9 @@ from counter.models import (
Product,
Refilling,
ReturnableProduct,
ScheduledProductAction,
StudentCard,
get_product_actions,
)
from counter.widgets.ajax_select import (
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):
error_css_class = "error"
required_css_class = "required"
@@ -199,22 +313,21 @@ 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.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):
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.action_formset.save()
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.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
@@ -445,7 +446,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):
@@ -1362,3 +1364,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)

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

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"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"
"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"
@@ -561,6 +561,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
@@ -1713,8 +1714,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities."
msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
"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
msgid "Contacts"
@@ -2157,10 +2158,6 @@ msgstr ""
msgid "Page history"
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
msgid "Page properties"
msgstr "Propriétés de la page"
@@ -2896,6 +2893,18 @@ msgstr "Cet UID est invalide"
msgid "User not found"
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
msgid ""
"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 ""
"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"
@@ -3548,6 +3569,25 @@ 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
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
msgid "Product list"
msgstr "Liste des produits"

View File

@@ -74,7 +74,7 @@ dev = [
tests = [
"freezegun<2.0.0,>=1.5.1",
"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",
"model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.13.3,<5",