1 Commits

Author SHA1 Message Date
imperosol
b60bd3a42b fix: product scheduled action on product creation
cf. issue #1257
2025-11-21 11:13:06 +01:00
6 changed files with 74 additions and 26 deletions

View File

@@ -1,7 +1,7 @@
import json import json
import math import math
import uuid import uuid
from datetime import date, datetime, timezone from datetime import date
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
@@ -235,6 +235,19 @@ class ScheduledProductActionForm(forms.ModelForm):
) )
return super().clean() return super().clean()
def set_product(self, product: Product):
"""Set the product to which this form's instance is linked.
When this form is linked to a ProductForm in the case of a product's creation,
the product doesn't exist yet, so saving this form as is will result
in having `{"product_id": null}` in the action kwargs.
For the creation to be useful, it may be needed to inject the newly created
product into this form, before saving the latter.
"""
self.product = product
kwargs = json.loads(self.instance.kwargs) | {"product_id": self.product.id}
self.instance.kwargs = json.dumps(kwargs)
class BaseScheduledProductActionFormSet(BaseModelFormSet): class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs): def __init__(self, *args, product: Product, **kwargs):
@@ -321,11 +334,19 @@ class ProductForm(forms.ModelForm):
def is_valid(self): def is_valid(self):
return super().is_valid() and self.action_formset.is_valid() return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs): def save(self, *args, **kwargs) -> Product:
ret = super().save(*args, **kwargs) product = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"]) product.counters.set(self.cleaned_data["counters"])
for form in self.action_formset:
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
form.set_product(product)
self.action_formset.save() self.action_formset.save()
return ret return product
class ReturnableProductForm(forms.ModelForm): class ReturnableProductForm(forms.ModelForm):
@@ -369,7 +390,6 @@ class EticketForm(forms.ModelForm):
class CloseCustomerAccountForm(forms.Form): class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_("Refound this account"), label=_("Refound this account"),
help_text=None,
required=True, required=True,
widget=AutoCompleteSelectUser, widget=AutoCompleteSelectUser,
queryset=User.objects.all(), queryset=User.objects.all(),
@@ -489,14 +509,13 @@ class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs): def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.month = month self.month = month
month_start = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
self.clubs = list( self.clubs = list(
Club.objects.filter( Club.objects.filter(
Exists( Exists(
Selling.objects.filter( Selling.objects.filter(
club=OuterRef("pk"), club=OuterRef("pk"),
date__gte=month_start, date__gte=month,
date__lte=month_start + relativedelta(months=1), date__lte=month + relativedelta(months=1),
) )
) )
).annotate( ).annotate(

View File

@@ -11,8 +11,12 @@ from model_bakery import baker
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet from counter.forms import (
from counter.models import ScheduledProductAction ProductForm,
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
@pytest.mark.django_db @pytest.mark.django_db
@@ -34,6 +38,39 @@ def test_edit_product(client: Client):
assert res.status_code == 200 assert res.status_code == 200
@pytest.mark.django_db
def test_create_actions_alongside_product():
"""The form should work when the product and the actions are created alongside."""
# non-persisted instance
product: Product = product_recipe.prepare(_save_related=True)
trigger_at = now() + timedelta(minutes=10)
form = ProductForm(
data={
"name": "foo",
"description": "bar",
"product_type": product.product_type_id,
"club": product.club_id,
"code": "FOO",
"purchase_price": 1.0,
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product",
"form-0-trigger_at": trigger_at,
},
)
assert form.is_valid()
product = form.save()
action = ScheduledProductAction.objects.last()
assert action.clocked.clocked_time == trigger_at
assert action.enabled is True
assert action.one_off is True
assert action.task == "counter.tasks.archive_product"
assert action.kwargs == json.dumps({"product_id": product.id})
@pytest.mark.django_db @pytest.mark.django_db
class TestProductActionForm: class TestProductActionForm:
def test_single_form_archive(self): def test_single_form_archive(self):

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -57,7 +57,7 @@ def test_invoice_call_view(client: Client, query: dict | None):
@pytest.mark.django_db @pytest.mark.django_db
def test_invoice_call_form(): def test_invoice_call_form():
Selling.objects.all().delete() Selling.objects.all().delete()
month = now() - relativedelta(months=1) month = localdate() - relativedelta(months=1)
clubs = baker.make(Club, _quantity=2) clubs = baker.make(Club, _quantity=2)
recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000)) recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000))
recipe.make(club=clubs[0], quantity=2, unit_price=200) recipe.make(club=clubs[0], quantity=2, unit_price=200)

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import datetime, timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
@@ -23,7 +23,6 @@ from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
@@ -286,13 +285,7 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add stats to the context.""" """Add stats to the context."""
counter: Counter = self.object counter: Counter = self.object
start_date = get_start_of_semester() semester_start = get_start_of_semester()
semester_start = datetime(
start_date.year,
start_date.month,
start_date.day,
tzinfo=get_current_timezone(),
)
office_hours = counter.get_top_barmen() office_hours = counter.get_top_barmen()
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs.update( kwargs.update(

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import datetime, timezone from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -63,8 +63,7 @@ class InvoiceCallView(
"""Add sums to the context.""" """Add sums to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC") kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
month = self.get_month() start_date = self.get_month()
start_date = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
end_date = start_date + relativedelta(months=1) end_date = start_date + relativedelta(months=1)
kwargs["sum_cb"] = Refilling.objects.filter( kwargs["sum_cb"] = Refilling.objects.filter(

View File

@@ -216,7 +216,7 @@ TEMPLATES = [
}, },
}, },
] ]
FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
HAYSTACK_CONNECTIONS = { HAYSTACK_CONNECTIONS = {
"default": { "default": {