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 %} +