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/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 c7df1d9d..9b1a9ab6 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,13 +1,23 @@ +import json import math +import uuid from django import forms from django.db.models import Q +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 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 +32,9 @@ from counter.models import ( Product, Refilling, ReturnableProduct, + ScheduledProductAction, StudentCard, + get_product_actions, ) from counter.widgets.ajax_select import ( AutoCompleteSelectMultipleCounter, @@ -158,7 +170,101 @@ class CounterEditForm(forms.ModelForm): } -class ProductEditForm(forms.ModelForm): +class ScheduledProductActionForm(forms.ModelForm): + """Form for automatic product archiving. + + The `save` method will update or create tasks using celery-beat. + """ + + required_css_class = "required" + prefix = "scheduled" + + class Meta: + model = ScheduledProductAction + fields = ["task"] + widgets = {"task": forms.RadioSelect(choices=get_product_actions)} + labels = {"task": _("Action")} + help_texts = {"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) + 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" + ) + + def clean(self): + 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: + self.instance.clocked = ClockedSchedule( + clocked_time=self.cleaned_data["trigger_at"] + ) + else: + 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() + + +class BaseScheduledProductActionFormSet(BaseModelFormSet): + def __init__(self, *args, product: Product, **kwargs): + if product.id: + queryset = ( + product.scheduled_actions.filter( + enabled=True, clocked__clocked_time__gt=now() + ) + .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) + + 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, + can_delete=True, + can_delete_extra=False, + extra=2, +) + + +class ProductForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" @@ -199,22 +305,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 @@ -266,7 +371,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) @@ -371,5 +476,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/migrations/0032_scheduledproductaction.py b/counter/migrations/0032_scheduledproductaction.py new file mode 100644 index 00000000..f312a095 --- /dev/null +++ b/counter/migrations/0032_scheduledproductaction.py @@ -0,0 +1,40 @@ +# 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"}, + bases=("django_celery_beat.periodictask",), + ), + ] diff --git a/counter/models.py b/counter/models.py index 94f80d2c..685071c3 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 @@ -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): @@ -479,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. @@ -1357,3 +1359,39 @@ 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") + + def __init__(self, *args, **kwargs): + self._meta.get_field("task").choices = get_product_actions() + super().__init__(*args, **kwargs) + + 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) diff --git a/counter/tasks.py b/counter/tasks.py new file mode 100644 index 00000000..af5b5505 --- /dev/null +++ b/counter/tasks.py @@ -0,0 +1,19 @@ +# Create your tasks here + +from celery import shared_task + +from counter.models import Counter, Product + + +@shared_task +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) diff --git a/counter/templates/counter/product_form.jinja b/counter/templates/counter/product_form.jinja new file mode 100644 index 00000000..afc9ba2a --- /dev/null +++ b/counter/templates/counter/product_form.jinja @@ -0,0 +1,56 @@ +{% extends "core/base.jinja" %} + +{% block content %} + {% if object %} +

{% trans name=object %}Edit product {{ name }}{% endtrans %}

+ {% else %} +

{% trans %}Product creation{% endtrans %}

+ {% endif %} +
+ {% csrf_token %} + {{ form.as_p() }} + +
+ +

{% trans %}Automatic actions{% endtrans %}

+ +

+ + {%- trans trimmed -%} + Automatic actions allows to schedule product changes + ahead of time. + {%- endtrans -%} + +

+ + {{ form.action_formset.management_form }} + {%- for action_form in form.action_formset.forms -%} +
+ {{ action_form.non_field_errors() }} +
+
+ {{ action_form.task.errors }} + {{ action_form.task.label_tag() }} + {{ action_form.task|add_attr("x-model=action") }} +
+
{{ action_form.trigger_at.as_field_group() }}
+
+
+ {{ action_form.counters.as_field_group() }} +
+ {%- if action_form.DELETE -%} +
+ {{ action_form.DELETE.as_field_group() }} +
+ {%- endif -%} + {%- for field in action_form.hidden_fields() -%} + {{ field }} + {%- endfor -%} +
+ {%- if not loop.last -%} +
+ {%- 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 new file mode 100644 index 00000000..bc7f6937 --- /dev/null +++ b/counter/tests/test_auto_actions.py @@ -0,0 +1,116 @@ +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 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_delete(self): + product = product_recipe.make() + clocked = baker.make(ClockedSchedule, clocked_time=now() + timedelta(minutes=2)) + task = baker.make( + ScheduledProductAction, + product=product, + one_off=True, + clocked=clocked, + task="counter.tasks.archive_product", + ) + 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 +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 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 ddd7a40e..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,8 +146,8 @@ class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): """A create view for the admins.""" model = Product - form_class = ProductEditForm - template_name = "core/create.jinja" + form_class = ProductForm + template_name = "counter/product_form.jinja" current_tab = "products" @@ -155,9 +155,9 @@ 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 = "core/edit.jinja" + 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 ) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 2ce8cde2..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" @@ -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,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, " @@ -3289,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" @@ -3607,6 +3632,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"