8 Commits

Author SHA1 Message Date
imperosol
f55627a292 refactor: core/views/index.py 2025-11-24 09:25:38 +01:00
thomas girod
4f802ac56e Merge pull request #1260 from ae-utbm/fix-warnings
Fix warnings
2025-11-24 07:43:51 +01:00
thomas girod
16a6e07d4b Merge pull request #1259 from ae-utbm/update-ninja
deps: bump django-ninja to 1.5.0
2025-11-24 07:43:39 +01:00
thomas girod
33d6300131 Merge pull request #1258 from ae-utbm/fix/product-action
fix: product scheduled action on product creation
2025-11-24 07:43:20 +01:00
imperosol
6709befb1f fix timezone issues 2025-11-23 01:30:44 +01:00
imperosol
ddfb88ca2a remove settings.FORM_RENDERER 2025-11-23 01:22:15 +01:00
imperosol
acdb9660f6 deps: bump django-ninja to 1.5.0 2025-11-23 00:48:32 +01:00
imperosol
b60bd3a42b fix: product scheduled action on product creation
cf. issue #1257
2025-11-21 11:13:06 +01:00
36 changed files with 894 additions and 773 deletions

View File

@@ -1,18 +1,16 @@
from typing import Annotated from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from club.models import Club, Membership from club.models import Club, Membership
from core.schemas import SimpleUserSchema from core.schemas import NonEmptyStr, SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema): class ClubSearchFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains") search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
is_active: bool | None = None is_active: bool | None = None
parent_id: int | None = None parent_id: int | None = None
parent_name: str | None = Field(None, q="parent__name__icontains")
exclude_ids: set[int] | None = None exclude_ids: set[int] | None = None
def filter_exclude_ids(self, value: set[int] | None): def filter_exclude_ids(self, value: set[int] | None):

View File

@@ -1,9 +1,9 @@
from datetime import datetime from datetime import datetime
from typing import Annotated
from ninja import FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver from ninja_extra import service_resolver
from ninja_extra.context import RouteContext from ninja_extra.context import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema from club.schemas import ClubProfileSchema
from com.models import News, NewsDate from com.models import News, NewsDate
@@ -11,12 +11,12 @@ from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema): class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt") before: Annotated[datetime | None, FilterLookup("end_date__lt")] = None
after: datetime | None = Field(None, q="start_date__gt") after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None
club_id: int | None = Field(None, q="news__club_id") club_id: Annotated[int | None, FilterLookup("news__club_id")] = None
news_id: int | None = None news_id: int | None = None
is_published: bool | None = Field(None, q="news__is_published") is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None
title: str | None = Field(None, q="news__title__icontains") title: Annotated[str | None, FilterLookup("news__title__icontains")] = None
class NewsSchema(ModelSchema): class NewsSchema(ModelSchema):

View File

@@ -350,6 +350,7 @@ class Command(BaseCommand):
date=make_aware( date=make_aware(
self.faker.date_time_between(customer.since, localdate()) self.faker.date_time_between(customer.since, localdate())
), ),
is_validated=True,
) )
) )
sales.extend(this_customer_sales) sales.extend(this_customer_sales)

View File

@@ -15,6 +15,8 @@ from pydantic_core.core_schema import ValidationInfo
from core.models import Group, QuickUploadImage, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import is_image from core.utils import is_image
NonEmptyStr = Annotated[str, MinLen(1)]
class UploadedImage(UploadedFile): class UploadedImage(UploadedFile):
@classmethod @classmethod

View File

@@ -9,19 +9,17 @@
{% block content %} {% block content %}
<h4>{% trans %}Users{% endtrans %}</h4> <h4>{% trans %}Users{% endtrans %}</h4>
<ul> <ul>
{% for i in result.users %} {% for user in users %}
{% if user.can_view(i) %} <li>
<li> {{ user_link_with_pict(user) }}
{{ user_link_with_pict(i) }} </li>
</li>
{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
<h4>{% trans %}Clubs{% endtrans %}</h4> <h4>{% trans %}Clubs{% endtrans %}</h4>
<ul> <ul>
{% for i in result.clubs %} {% for club in clubs %}
<li> <li>
<a href="{{ url("club:club_view", club_id=i.id) }}">{{ i }}</a> <a href="{{ url("club:club_view", club_id=club.id) }}">{{ club }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -35,6 +35,7 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester from core.utils import get_last_promo, get_semester_code, get_start_of_semester
@@ -551,3 +552,10 @@ def test_allow_fragment_mixin():
assert not TestAllowFragmentView.as_view()(request) assert not TestAllowFragmentView.as_view()(request)
request.headers = {"HX-Request": True, **base_headers} request.headers = {"HX-Request": True, **base_headers}
assert TestAllowFragmentView.as_view()(request) assert TestAllowFragmentView.as_view()(request)
@pytest.mark.django_db
def test_search_view(client: Client):
client.force_login(subscriber_user.make())
response = client.get(reverse("core:search", query={"query": "foo"}))
assert response.status_code == 200

View File

@@ -0,0 +1,64 @@
from datetime import timedelta
from operator import attrgetter
import pytest
from bs4 import BeautifulSoup
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Notification
@pytest.mark.django_db
class TestNotificationList(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
url = reverse("core:user_profile", kwargs={"user_id": cls.user.id})
cls.notifs = baker.make(
Notification,
user=cls.user,
url=url,
viewed=False,
date=seq(now() - timedelta(days=1), timedelta(hours=1)),
_quantity=10,
_bulk_create=True,
)
def test_list(self):
self.client.force_login(self.user)
response = self.client.get(reverse("core:notification_list"))
assert response.status_code == 200
soup = BeautifulSoup(response.text, "lxml")
ul = soup.find("ul", id="notifications")
elements = list(ul.find_all("li"))
assert len(elements) == len(self.notifs)
notifs = sorted(self.notifs, key=attrgetter("date"), reverse=True)
for element, notif in zip(elements, notifs, strict=True):
assert element.find("a")["href"] == reverse(
"core:notification", kwargs={"notif_id": notif.id}
)
def test_read_all(self):
self.client.force_login(self.user)
response = self.client.get(
reverse("core:notification_list", query={"read_all": None})
)
assert response.status_code == 200
assert not self.user.notifications.filter(viewed=True).exists()
@pytest.mark.django_db
def test_notification_redirect(client: Client):
user = subscriber_user.make()
url = reverse("core:user_profile", kwargs={"user_id": user.id})
notif = baker.make(Notification, user=user, url=url, viewed=False)
client.force_login(user)
response = client.get(reverse("core:notification", kwargs={"notif_id": notif.id}))
assertRedirects(response, url)
notif.refresh_from_db()
assert notif.viewed is True

View File

@@ -187,7 +187,11 @@ class TestFilterInactive(TestCase):
time_inactive = time_active - timedelta(days=3) time_inactive = time_active - timedelta(days=3)
counter, seller = baker.make(Counter), baker.make(User) counter, seller = baker.make(Counter), baker.make(User)
sale_recipe = Recipe( sale_recipe = Recipe(
Selling, counter=counter, club=counter.club, seller=seller, unit_price=0 Selling,
counter=counter,
club=counter.club,
seller=seller,
is_validated=True,
) )
cls.users = [ cls.users = [

View File

@@ -24,6 +24,7 @@
from django.urls import path, re_path, register_converter from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView from django.views.generic import RedirectView
from com.views import NewsListView
from core.converters import ( from core.converters import (
BooleanStringConverter, BooleanStringConverter,
FourDigitYearConverter, FourDigitYearConverter,
@@ -53,6 +54,7 @@ from core.views import (
PagePropView, PagePropView,
PageRevView, PageRevView,
PageView, PageView,
SearchView,
SithLoginView, SithLoginView,
SithPasswordChangeDoneView, SithPasswordChangeDoneView,
SithPasswordChangeView, SithPasswordChangeView,
@@ -76,13 +78,9 @@ from core.views import (
UserUpdateProfileView, UserUpdateProfileView,
UserView, UserView,
delete_user_godfather, delete_user_godfather,
index,
logout, logout,
notification, notification,
password_root_change, password_root_change,
search_json,
search_user_json,
search_view,
send_file, send_file,
) )
@@ -91,13 +89,11 @@ register_converter(TwoDigitMonthConverter, "mm")
register_converter(BooleanStringConverter, "bool") register_converter(BooleanStringConverter, "bool")
urlpatterns = [ urlpatterns = [
path("", index, name="index"), path("", NewsListView.as_view(), name="index"),
path("notifications/", NotificationList.as_view(), name="notification_list"), path("notifications/", NotificationList.as_view(), name="notification_list"),
path("notification/<int:notif_id>/", notification, name="notification"), path("notification/<int:notif_id>/", notification, name="notification"),
# Search # Search
path("search/", search_view, name="search"), path("search/", SearchView.as_view(), name="search"),
path("search_json/", search_json, name="search_json"),
path("search_user/", search_user_json, name="search_user"),
# Login and co # Login and co
path("login/", SithLoginView.as_view(), name="login"), path("login/", SithLoginView.as_view(), name="login"),
path("logout/", logout, name="logout"), path("logout/", logout, name="logout"),

View File

@@ -22,106 +22,49 @@
# #
# #
import json
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin
from django.core import serializers from django.db.models import F
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import JsonResponse from django.http import HttpRequest
from django.shortcuts import redirect, render from django.shortcuts import get_object_or_404, redirect
from django.utils import html from django.views.generic import ListView, TemplateView
from django.utils.text import slugify
from django.views.generic import ListView
from haystack.query import SearchQuerySet
from club.models import Club from club.models import Club
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UserFilterSchema
def index(request, context=None): class NotificationList(LoginRequiredMixin, ListView):
from com.views import NewsListView
return NewsListView.as_view()(request)
class NotificationList(ListView):
model = Notification model = Notification
template_name = "core/notification_list.jinja" template_name = "core/notification_list.jinja"
def get_queryset(self) -> QuerySet[Notification]: def get_queryset(self) -> QuerySet[Notification]:
if self.request.user.is_anonymous:
return Notification.objects.none()
# TODO: Bulk update in django 2.2
if "see_all" in self.request.GET: if "see_all" in self.request.GET:
self.request.user.notifications.filter(viewed=False).update(viewed=True) self.request.user.notifications.filter(viewed=False).update(viewed=True)
return self.request.user.notifications.order_by("-date")[:20] return self.request.user.notifications.order_by("-date")[:20]
def notification(request, notif_id): def notification(request: HttpRequest, notif_id: int):
notif = Notification.objects.filter(id=notif_id).first() notif = get_object_or_404(Notification, id=notif_id)
if notif: if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS: notif.viewed = True
notif.viewed = True else:
else: notif.callback()
notif.callback() notif.save()
notif.save() return redirect(notif.url)
return redirect(notif.url)
return redirect("/")
def search_user(query): class SearchView(LoginRequiredMixin, TemplateView):
try: template_name = "core/search.jinja"
# slugify turns everything into ascii and every whitespace into -
# it ends by removing duplicate - (so ' - ' will turn into '-')
# replace('-', ' ') because search is whitespace based
query = slugify(query).replace("-", " ")
# TODO: is this necessary?
query = html.escape(query)
res = (
SearchQuerySet()
.models(User)
.autocomplete(auto=query)
.order_by("-last_login")
.load_all()[:20]
)
return [r.object for r in res]
except TypeError:
return []
def get_context_data(self, **kwargs):
def search_club(query, *, as_json=False): users, clubs = [], []
clubs = [] if query := self.request.GET.get("query"):
if query: users = list(
clubs = Club.objects.filter(name__icontains=query).all() UserFilterSchema(search=query)
clubs = clubs[:5] .filter(User.objects.viewable_by(self.request.user))
if as_json: .order_by(F("last_login").desc(nulls_last=True))
# Re-loads json to avoid double encoding by JsonResponse, but still benefit from serializers )
clubs = json.loads(serializers.serialize("json", clubs, fields=("name"))) clubs = list(Club.objects.filter(name__icontains=query)[:5])
else: return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
clubs = list(clubs)
return clubs
@login_required
def search_view(request):
result = {
"users": search_user(request.GET.get("query", "")),
"clubs": search_club(request.GET.get("query", "")),
}
return render(request, "core/search.jinja", context={"result": result})
@login_required
def search_user_json(request):
result = {"users": search_user(request.GET.get("query", ""))}
return JsonResponse(result)
@login_required
def search_json(request):
result = {
"users": search_user(request.GET.get("query", "")),
"clubs": search_club(request.GET.get("query", ""), as_json=True),
}
return JsonResponse(result)

View File

@@ -24,6 +24,12 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
class CounterConfig(AppConfig): class CounterConfig(AppConfig):
name = "counter" name = "counter"

View File

@@ -1,7 +1,7 @@
import json import json
import math import math
import uuid import uuid
from datetime import date from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
@@ -136,10 +136,7 @@ class GetUserForm(forms.Form):
class RefillForm(forms.ModelForm): class RefillForm(forms.ModelForm):
allowed_refilling_methods = [ allowed_refilling_methods = ["CASH", "CARD"]
Refilling.PaymentMethod.CASH,
Refilling.PaymentMethod.CARD,
]
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -149,7 +146,7 @@ class RefillForm(forms.ModelForm):
class Meta: class Meta:
model = Refilling model = Refilling
fields = ["amount", "payment_method"] fields = ["amount", "payment_method", "bank"]
widgets = {"payment_method": forms.RadioSelect} widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -163,6 +160,9 @@ class RefillForm(forms.ModelForm):
if self.fields["payment_method"].initial not in self.allowed_refilling_methods: if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0] self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
if "CHECK" not in self.allowed_refilling_methods:
del self.fields["bank"]
class CounterEditForm(forms.ModelForm): class CounterEditForm(forms.ModelForm):
class Meta: class Meta:
@@ -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,13 +509,14 @@ 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, date__gte=month_start,
date__lte=month + relativedelta(months=1), date__lte=month_start + relativedelta(months=1),
) )
) )
).annotate( ).annotate(

View File

@@ -119,6 +119,7 @@ class Command(BaseCommand):
quantity=1, quantity=1,
unit_price=account.amount, unit_price=account.amount,
date=now(), date=now(),
is_validated=True,
) )
for account in accounts for account in accounts
] ]

View File

@@ -1,84 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method_str="CARD").update(payment_method=1)
def migrate_selling_payment_method_reverse(apps: StateApps, schema_editor):
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method=1).update(payment_method_str="CARD")
def migrate_refilling_payment_method(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method=Case(
When(payment_method_str="CARD", then=0),
When(payment_method_str="CASH", then=1),
When(payment_method_str="CHECK", then=2),
)
)
def migrate_refilling_payment_method_reverse(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method_str=Case(
When(payment_method=0, then="CARD"),
When(payment_method=1, then="CASH"),
When(payment_method=2, then="CHECK"),
)
)
class Migration(migrations.Migration):
dependencies = [("counter", "0034_alter_selling_date_selling_date_month_idx")]
operations = [
migrations.RemoveField(model_name="selling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="bank"),
migrations.RenameField(
model_name="selling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="selling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Sith account"), (1, "Credit card")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_selling_payment_method, migrate_selling_payment_method_reverse
),
migrations.RemoveField(model_name="selling", name="payment_method_str"),
migrations.RenameField(
model_name="refilling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="refilling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Credit card"), (1, "Cash"), (2, "Check")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_refilling_payment_method, migrate_refilling_payment_method_reverse
),
migrations.RemoveField(model_name="refilling", name="payment_method_str"),
]

View File

@@ -44,6 +44,7 @@ from club.models import Club
from core.fields import ResizedImageField from core.fields import ResizedImageField
from core.models import Group, Notification, User from core.models import Group, Notification, User
from core.utils import get_start_of_semester from core.utils import get_start_of_semester
from counter.apps import PAYMENT_METHOD
from counter.fields import CurrencyField from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
@@ -79,8 +80,7 @@ class CustomerQuerySet(models.QuerySet):
) )
money_out = Subquery( money_out = Subquery(
Selling.objects.filter( Selling.objects.filter(
customer=OuterRef("pk"), customer=OuterRef("pk"), payment_method="SITH_ACCOUNT"
payment_method=Selling.PaymentMethod.SITH_ACCOUNT,
) )
.values("customer_id") .values("customer_id")
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0)) .annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
@@ -731,11 +731,6 @@ class RefillingQuerySet(models.QuerySet):
class Refilling(models.Model): class Refilling(models.Model):
"""Handle the refilling.""" """Handle the refilling."""
class PaymentMethod(models.IntegerChoices):
CARD = 0, _("Credit card")
CASH = 1, _("Cash")
CHECK = 2, _("Check")
counter = models.ForeignKey( counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
) )
@@ -750,9 +745,16 @@ class Refilling(models.Model):
Customer, related_name="refillings", blank=False, on_delete=models.CASCADE Customer, related_name="refillings", blank=False, on_delete=models.CASCADE
) )
date = models.DateTimeField(_("date")) date = models.DateTimeField(_("date"))
payment_method = models.PositiveSmallIntegerField( payment_method = models.CharField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.CARD _("payment method"),
max_length=255,
choices=PAYMENT_METHOD,
default="CARD",
) )
bank = models.CharField(
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
)
is_validated = models.BooleanField(_("is validated"), default=False)
objects = RefillingQuerySet.as_manager() objects = RefillingQuerySet.as_manager()
@@ -769,9 +771,10 @@ class Refilling(models.Model):
if not self.date: if not self.date:
self.date = timezone.now() self.date = timezone.now()
self.full_clean() self.full_clean()
if self._state.adding: if not self.is_validated:
self.customer.amount += self.amount self.customer.amount += self.amount
self.customer.save() self.customer.save()
self.is_validated = True
if self.customer.user.preferences.notify_on_refill: if self.customer.user.preferences.notify_on_refill:
Notification( Notification(
user=self.customer.user, user=self.customer.user,
@@ -811,10 +814,6 @@ class SellingQuerySet(models.QuerySet):
class Selling(models.Model): class Selling(models.Model):
"""Handle the sellings.""" """Handle the sellings."""
class PaymentMethod(models.IntegerChoices):
SITH_ACCOUNT = 0, _("Sith account")
CARD = 1, _("Credit card")
# We make sure that sellings have a way begger label than any product name is allowed to # We make sure that sellings have a way begger label than any product name is allowed to
label = models.CharField(_("label"), max_length=128) label = models.CharField(_("label"), max_length=128)
product = models.ForeignKey( product = models.ForeignKey(
@@ -851,9 +850,13 @@ class Selling(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
date = models.DateTimeField(_("date"), db_index=True) date = models.DateTimeField(_("date"), db_index=True)
payment_method = models.PositiveSmallIntegerField( payment_method = models.CharField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.SITH_ACCOUNT _("payment method"),
max_length=255,
choices=[("SITH_ACCOUNT", _("Sith account")), ("CARD", _("Credit card"))],
default="SITH_ACCOUNT",
) )
is_validated = models.BooleanField(_("is validated"), default=False)
objects = SellingQuerySet.as_manager() objects = SellingQuerySet.as_manager()
@@ -872,12 +875,10 @@ class Selling(models.Model):
if not self.date: if not self.date:
self.date = timezone.now() self.date = timezone.now()
self.full_clean() self.full_clean()
if ( if not self.is_validated:
self._state.adding
and self.payment_method == self.PaymentMethod.SITH_ACCOUNT
):
self.customer.amount -= self.quantity * self.unit_price self.customer.amount -= self.quantity * self.unit_price
self.customer.save(allow_negative=allow_negative) self.customer.save(allow_negative=allow_negative)
self.is_validated = True
user = self.customer.user user = self.customer.user
if user.was_subscribed: if user.was_subscribed:
if ( if (
@@ -947,9 +948,7 @@ class Selling(models.Model):
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
return False return False
return self.payment_method != self.PaymentMethod.CARD and user.is_owner( return self.payment_method != "CARD" and user.is_owner(self.counter)
self.counter
)
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
if ( if (
@@ -959,7 +958,7 @@ class Selling(models.Model):
return user == self.customer.user return user == self.customer.user
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
if self.payment_method == Selling.PaymentMethod.SITH_ACCOUNT: if self.payment_method == "SITH_ACCOUNT":
self.customer.amount += self.quantity * self.unit_price self.customer.amount += self.quantity * self.unit_price
self.customer.save() self.customer.save()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)

View File

@@ -1,13 +1,12 @@
from datetime import datetime from datetime import datetime
from typing import Annotated, Self from typing import Annotated, Self
from annotated_types import MinLen
from django.urls import reverse from django.urls import reverse
from ninja import Field, FilterSchema, ModelSchema, Schema from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import model_validator from pydantic import model_validator
from club.schemas import SimpleClubSchema from club.schemas import SimpleClubSchema
from core.schemas import GroupSchema, SimpleUserSchema from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Product, ProductType from counter.models import Counter, Product, ProductType
@@ -21,7 +20,7 @@ class CounterSchema(ModelSchema):
class CounterFilterSchema(FilterSchema): class CounterFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains") search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
class SimplifiedCounterSchema(ModelSchema): class SimplifiedCounterSchema(ModelSchema):
@@ -93,18 +92,18 @@ class ProductSchema(ModelSchema):
class ProductFilterSchema(FilterSchema): class ProductFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field( search: Annotated[
None, q=["name__icontains", "code__icontains"] NonEmptyStr | None, FilterLookup(["name__icontains", "code__icontains"])
) ] = None
is_archived: bool | None = Field(None, q="archived") is_archived: Annotated[bool | None, FilterLookup("archived")] = None
buying_groups: set[int] | None = Field(None, q="buying_groups__in") buying_groups: Annotated[set[int] | None, FilterLookup("buying_groups__in")] = None
product_type: set[int] | None = Field(None, q="product_type__in") product_type: Annotated[set[int] | None, FilterLookup("product_type__in")] = None
club: set[int] | None = Field(None, q="club__in") club: Annotated[set[int] | None, FilterLookup("club__in")] = None
counter: set[int] | None = Field(None, q="counters__in") counter: Annotated[set[int] | None, FilterLookup("counters__in")] = None
class SaleFilterSchema(FilterSchema): class SaleFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="date__lt") before: Annotated[datetime | None, FilterLookup("date__lt")] = None
after: datetime | None = Field(None, q="date__gt") after: Annotated[datetime | None, FilterLookup("date__gt")] = None
counters: set[int] | None = Field(None, q="counter__in") counters: Annotated[set[int] | None, FilterLookup("counter__in")] = None
products: set[int] | None = Field(None, q="product__in") products: Annotated[set[int] | None, FilterLookup("product__in")] = None

View File

@@ -116,6 +116,7 @@ class TestAccountDumpCommand(TestAccountDump):
operation: Selling = customer.buyings.order_by("date").last() operation: Selling = customer.buyings.order_by("date").last()
assert operation.unit_price == initial_amount assert operation.unit_price == initial_amount
assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID
assert operation.is_validated is True
dump = customer.dumps.last() dump = customer.dumps.last()
assert dump.dump_operation == operation assert dump.dump_operation == operation

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

@@ -53,7 +53,7 @@ def set_age(user: User, age: int):
def force_refill_user(user: User, amount: Decimal | int): def force_refill_user(user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer) baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
class TestFullClickBase(TestCase): class TestFullClickBase(TestCase):
@@ -115,10 +115,18 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse: ) -> HttpResponse:
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
return used_client.post( return used_client.post(
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}), reverse(
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH}, "counter:refilling_create",
kwargs={"customer_id": user.pk},
),
{
"amount": str(amount),
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=reverse( HTTP_REFERER=reverse(
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk} "counter:click",
kwargs={"counter_id": counter.id, "user_id": user.pk},
), ),
) )
@@ -141,7 +149,11 @@ class TestRefilling(TestFullClickBase):
"counter:refilling_create", "counter:refilling_create",
kwargs={"customer_id": self.customer.pk}, kwargs={"customer_id": self.customer.pk},
), ),
{"amount": "10", "payment_method": "CASH"}, {
"amount": "10",
"payment_method": "CASH",
"bank": "OTHER",
},
) )
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)

View File

@@ -298,6 +298,7 @@ def test_update_balance():
_quantity=len(customers), _quantity=len(customers),
unit_price=10, unit_price=10,
quantity=1, quantity=1,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
*sale_recipe.prepare( *sale_recipe.prepare(
@@ -305,12 +306,14 @@ def test_update_balance():
_quantity=3, _quantity=3,
unit_price=5, unit_price=5,
quantity=2, quantity=2,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
sale_recipe.prepare( sale_recipe.prepare(
customer=customers[4], customer=customers[4],
quantity=1, quantity=1,
unit_price=50, unit_price=50,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
*sale_recipe.prepare( *sale_recipe.prepare(
@@ -321,7 +324,7 @@ def test_update_balance():
_quantity=len(customers), _quantity=len(customers),
unit_price=50, unit_price=50,
quantity=1, quantity=1,
payment_method=Selling.PaymentMethod.CARD, payment_method="CARD",
_save_related=True, _save_related=True,
), ),
] ]

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 localdate from django.utils.timezone import now
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 = localdate() - relativedelta(months=1) month = now() - 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 timedelta from datetime import datetime, 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,6 +23,7 @@ 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
@@ -285,7 +286,13 @@ 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
semester_start = get_start_of_semester() start_date = 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 from datetime import datetime, timezone
from urllib.parse import urlencode from urllib.parse import urlencode
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -63,17 +63,20 @@ 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")
start_date = self.get_month() month = 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(
payment_method=Refilling.PaymentMethod.CARD, payment_method="CARD",
is_validated=True,
date__gte=start_date, date__gte=start_date,
date__lte=end_date, date__lte=end_date,
).aggregate(res=Sum("amount", default=0))["res"] ).aggregate(res=Sum("amount", default=0))["res"]
kwargs["sum_cb"] += ( kwargs["sum_cb"] += (
Selling.objects.filter( Selling.objects.filter(
payment_method=Selling.PaymentMethod.CARD, payment_method="CARD",
is_validated=True,
date__gte=start_date, date__gte=start_date,
date__lte=end_date, date__lte=end_date,
) )

View File

@@ -110,9 +110,7 @@ class Basket(models.Model):
)["total"] )["total"]
) )
def generate_sales( def generate_sales(self, counter, seller: User, payment_method: str):
self, counter, seller: User, payment_method: Selling.PaymentMethod
):
"""Generate a list of sold items corresponding to the items """Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket. of this basket WITHOUT saving them NOR deleting the basket.
@@ -253,7 +251,8 @@ class Invoice(models.Model):
customer=customer, customer=customer,
operator=self.user, operator=self.user,
amount=i.product_unit_price * i.quantity, amount=i.product_unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD, payment_method="CARD",
bank="OTHER",
date=self.date, date=self.date,
) )
new.save() new.save()
@@ -268,7 +267,8 @@ class Invoice(models.Model):
customer=customer, customer=customer,
unit_price=i.product_unit_price, unit_price=i.product_unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD, payment_method="CARD",
is_validated=True,
date=self.date, date=self.date,
) )
new.save() new.save()

View File

@@ -108,22 +108,12 @@ def test_eboutic_basket_expiry(
client.force_login(customer.user) client.force_login(customer.user)
if sellings: for date in sellings:
sale_recipe.make( sale_recipe.make(
customer=customer, customer=customer, counter=eboutic, date=date, is_validated=True
counter=eboutic,
date=iter(sellings),
_quantity=len(sellings),
_bulk_create=True,
)
if refillings:
refill_recipe.make(
customer=customer,
counter=eboutic,
date=iter(refillings),
_quantity=len(refillings),
_bulk_create=True,
) )
for date in refillings:
refill_recipe.make(customer=customer, counter=eboutic, date=date)
assert ( assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"' f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'

View File

@@ -114,13 +114,13 @@ class TestPaymentSith(TestPaymentBase):
"quantity" "quantity"
) )
assert len(sellings) == 2 assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT assert sellings[0].payment_method == "SITH_ACCOUNT"
assert sellings[0].quantity == 1 assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC" assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT assert sellings[1].payment_method == "SITH_ACCOUNT"
assert sellings[1].quantity == 2 assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC" assert sellings[1].counter.type == "EBOUTIC"
@@ -198,13 +198,13 @@ class TestPaymentCard(TestPaymentBase):
"quantity" "quantity"
) )
assert len(sellings) == 2 assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.CARD assert sellings[0].payment_method == "CARD"
assert sellings[0].quantity == 1 assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC" assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.CARD assert sellings[1].payment_method == "CARD"
assert sellings[1].quantity == 2 assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC" assert sellings[1].counter.type == "EBOUTIC"

View File

@@ -275,9 +275,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
sales = basket.generate_sales( sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
eboutic, basket.user, Selling.PaymentMethod.SITH_ACCOUNT
)
try: try:
with transaction.atomic(): with transaction.atomic():
# Selling.save has some important business logic in it. # Selling.save has some important business logic in it.

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-19 21:00+0100\n" "POT-Creation-Date: 2025-11-12 21:44+0100\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"
@@ -2928,6 +2928,18 @@ msgstr "Photos"
msgid "Account" msgid "Account"
msgstr "Compte" msgstr "Compte"
#: counter/apps.py sith/settings.py
msgid "Check"
msgstr "Chèque"
#: counter/apps.py sith/settings.py
msgid "Cash"
msgstr "Espèces"
#: counter/apps.py counter/models.py sith/settings.py
msgid "Credit card"
msgstr "Carte bancaire"
#: counter/apps.py counter/models.py #: counter/apps.py counter/models.py
msgid "counter" msgid "counter"
msgstr "comptoir" msgstr "comptoir"
@@ -3140,29 +3152,21 @@ msgstr "vendeurs"
msgid "token" msgid "token"
msgstr "jeton" msgstr "jeton"
#: counter/models.py sith/settings.py
msgid "Credit card"
msgstr "Carte bancaire"
#: counter/models.py sith/settings.py
msgid "Cash"
msgstr "Espèces"
#: counter/models.py sith/settings.py
msgid "Check"
msgstr "Chèque"
#: counter/models.py subscription/models.py #: counter/models.py subscription/models.py
msgid "payment method" msgid "payment method"
msgstr "méthode de paiement" msgstr "méthode de paiement"
#: counter/models.py #: counter/models.py
msgid "refilling" msgid "bank"
msgstr "rechargement" msgstr "banque"
#: counter/models.py #: counter/models.py
msgid "Sith account" msgid "is validated"
msgstr "Compte utilisateur" msgstr "est validé"
#: counter/models.py
msgid "refilling"
msgstr "rechargement"
#: counter/models.py eboutic/models.py #: counter/models.py eboutic/models.py
msgid "unit price" msgid "unit price"
@@ -3172,6 +3176,10 @@ msgstr "prix unitaire"
msgid "quantity" msgid "quantity"
msgstr "quantité" msgstr "quantité"
#: counter/models.py
msgid "Sith account"
msgstr "Compte utilisateur"
#: counter/models.py #: counter/models.py
msgid "selling" msgid "selling"
msgstr "vente" msgstr "vente"
@@ -3324,10 +3332,6 @@ msgid ""
"%(value)s” value has the correct format (YYYY-MM) but it is an invalid date." "%(value)s” value has the correct format (YYYY-MM) but it is an invalid date."
msgstr "La valeur « %(value)s » a le bon format, mais est une date invalide." msgstr "La valeur « %(value)s » a le bon format, mais est une date invalide."
#: counter/models.py
msgid "is validated"
msgstr "est validé"
#: counter/models.py #: counter/models.py
msgid "invoice date" msgid "invoice date"
msgstr "date de la facture" msgstr "date de la facture"

View File

@@ -24,6 +24,7 @@ from ast import literal_eval
from enum import Enum from enum import Enum
from django import forms from django import forms
from django.db.models import F
from django.http.response import HttpResponseRedirect from django.http.response import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -34,7 +35,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from core.auth.mixins import FormerSubscriberMixin from core.auth.mixins import FormerSubscriberMixin
from core.models import User from core.models import User
from core.views import search_user from core.schemas import UserFilterSchema
from core.views.forms import SelectDate from core.views.forms import SelectDate
# Enum to select search type # Enum to select search type
@@ -126,11 +127,13 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
q = q.filter(phone=self.valid_form["phone"]).all() q = q.filter(phone=self.valid_form["phone"]).all()
elif self.search_type == SearchType.QUICK: elif self.search_type == SearchType.QUICK:
if self.valid_form["quick"].strip(): if self.valid_form["quick"].strip():
q = search_user(self.valid_form["quick"]) q = list(
UserFilterSchema(search=self.valid_form["quick"])
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
)
else: else:
q = [] q = []
if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_viewable]
else: else:
search_dict = {} search_dict = {}
for key, value in self.valid_form.items(): for key, value in self.valid_form.items():

View File

@@ -1,9 +1,9 @@
from typing import Literal from typing import Annotated, Literal
from django.db.models import Q from django.db.models import Q
from django.utils import html from django.utils import html
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
@@ -114,13 +114,14 @@ class UvSchema(ModelSchema):
class UvFilterSchema(FilterSchema): class UvFilterSchema(FilterSchema):
search: str | None = Field(None, q="code__icontains") search: Annotated[str | None, FilterLookup("code__icontains")] = None
semester: set[Literal["AUTUMN", "SPRING"]] | None = None semester: set[Literal["AUTUMN", "SPRING"]] | None = None
credit_type: set[Literal["CS", "TM", "EC", "OM", "QC"]] | None = Field( credit_type: Annotated[
None, q="credit_type__in" set[Literal["CS", "TM", "EC", "OM", "QC"]] | None,
) FilterLookup("credit_type__in"),
] = None
language: str = "FR" language: str = "FR"
department: set[str] | None = Field(None, q="department__in") department: Annotated[set[str] | None, FilterLookup("department__in")] = None
def filter_search(self, value: str | None) -> Q: def filter_search(self, value: str | None) -> Q:
"""Special filter for the search text. """Special filter for the search text.

View File

@@ -20,8 +20,8 @@ license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12" requires-python = "<4.0,>=3.12"
dependencies = [ dependencies = [
"django>=5.2.8,<6.0.0", "django>=5.2.8,<6.0.0",
"django-ninja>=1.4.5,<2.0.0", "django-ninja>=1.5.0,<6.0.0",
"django-ninja-extra>=0.30.2,<1.0.0", "django-ninja-extra>=0.30.6",
"Pillow>=12.0.0,<13.0.0", "Pillow>=12.0.0,<13.0.0",
"mistune>=3.1.4,<4.0.0", "mistune>=3.1.4,<4.0.0",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",

View File

@@ -114,6 +114,7 @@ class TestMergeUser(TestCase):
seller=self.root, seller=self.root,
unit_price=2, unit_price=2,
quantity=2, quantity=2,
payment_method="SITH_ACCOUNT",
).save() ).save()
Selling( Selling(
label="barbar", label="barbar",
@@ -124,6 +125,7 @@ class TestMergeUser(TestCase):
seller=self.root, seller=self.root,
unit_price=2, unit_price=2,
quantity=4, quantity=4,
payment_method="SITH_ACCOUNT",
).save() ).save()
today = localtime(now()).date() today = localtime(now()).date()
# both subscriptions began last month and shall end in 5 months # both subscriptions began last month and shall end in 5 months
@@ -195,6 +197,7 @@ class TestMergeUser(TestCase):
seller=self.root, seller=self.root,
unit_price=2, unit_price=2,
quantity=4, quantity=4,
payment_method="SITH_ACCOUNT",
).save() ).save()
data = {"user1": self.to_keep.id, "user2": self.to_delete.id} data = {"user1": self.to_keep.id, "user2": self.to_delete.id}
res = self.client.post(reverse("rootplace:merge"), data) res = self.client.post(reverse("rootplace:merge"), data)
@@ -222,6 +225,7 @@ class TestMergeUser(TestCase):
seller=self.root, seller=self.root,
unit_price=2, unit_price=2,
quantity=4, quantity=4,
payment_method="SITH_ACCOUNT",
).save() ).save()
data = {"user1": self.to_keep.id, "user2": self.to_delete.id} data = {"user1": self.to_keep.id, "user2": self.to_delete.id}
res = self.client.post(reverse("rootplace:merge"), data) res = self.client.post(reverse("rootplace:merge"), data)

View File

@@ -2,20 +2,19 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from annotated_types import MinLen
from django.urls import reverse from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
from core.schemas import SimpleUserSchema, UserProfileSchema from core.schemas import NonEmptyStr, SimpleUserSchema, UserProfileSchema
from sas.models import Album, Picture, PictureModerationRequest from sas.models import Album, Picture, PictureModerationRequest
class AlbumFilterSchema(FilterSchema): class AlbumFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains") search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
before_date: datetime | None = Field(None, q="event_date__lte") before_date: Annotated[datetime | None, FilterLookup("event_date__lte")] = None
after_date: datetime | None = Field(None, q="event_date__gte") after_date: Annotated[datetime | None, FilterLookup("event_date__gte")] = None
parent_id: int | None = Field(None, q="parent_id") parent_id: Annotated[int | None, FilterLookup("parent_id")] = None
class SimpleAlbumSchema(ModelSchema): class SimpleAlbumSchema(ModelSchema):
@@ -60,10 +59,12 @@ class AlbumAutocompleteSchema(ModelSchema):
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
before_date: datetime | None = Field(None, q="date__lte") before_date: Annotated[datetime | None, FilterLookup("date__lte")] = None
after_date: datetime | None = Field(None, q="date__gte") after_date: Annotated[datetime | None, FilterLookup("date__gte")] = None
users_identified: set[int] | None = Field(None, q="people__user_id__in") users_identified: Annotated[
album_id: int | None = Field(None, q="parent_id") set[int] | None, FilterLookup("people__user_id__in")
] = None
album_id: Annotated[int | None, FilterLookup("parent_id")] = None
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):

View File

@@ -216,7 +216,7 @@ TEMPLATES = [
}, },
}, },
] ]
FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
HAYSTACK_CONNECTIONS = { HAYSTACK_CONNECTIONS = {
"default": { "default": {
@@ -440,6 +440,19 @@ SITH_SUBSCRIPTION_LOCATIONS = [
SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")] SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")]
SITH_COUNTER_BANK = [
("OTHER", "Autre"),
("SOCIETE-GENERALE", "Société générale"),
("BANQUE-POPULAIRE", "Banque populaire"),
("BNP", "BNP"),
("CAISSE-EPARGNE", "Caisse d'épargne"),
("CIC", "CIC"),
("CREDIT-AGRICOLE", "Crédit Agricole"),
("CREDIT-MUTUEL", "Credit Mutuel"),
("CREDIT-LYONNAIS", "Credit Lyonnais"),
("LA-POSTE", "La Poste"),
]
SITH_PEDAGOGY_UV_TYPE = [ SITH_PEDAGOGY_UV_TYPE = [
("FREE", _("Free")), ("FREE", _("Free")),
("CS", _("CS")), ("CS", _("CS")),

View File

@@ -24,6 +24,7 @@ from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from core.views.group import PermissionGroupsUpdateView from core.views.group import PermissionGroupsUpdateView
from counter.apps import PAYMENT_METHOD
from subscription.forms import ( from subscription.forms import (
SelectionDateForm, SelectionDateForm,
SubscriptionExistingUserForm, SubscriptionExistingUserForm,
@@ -128,6 +129,6 @@ class SubscriptionsStatsView(FormView):
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date subscription_end__gte=self.end_date, subscription_start__lte=self.start_date
) )
kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS
kwargs["payment_types"] = settings.SITH_SUBSCRIPTION_PAYMENT_METHOD kwargs["payment_types"] = PAYMENT_METHOD
kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
return kwargs return kwargs

955
uv.lock generated

File diff suppressed because it is too large Load Diff