6 Commits

Author SHA1 Message Date
imperosol
0c046b6164 translations 2025-11-19 21:03:55 +01:00
imperosol
c588e5117d make Refilling.payment_method a SmallIntegerField 2025-11-19 21:03:55 +01:00
imperosol
ad87617018 remove Refilling.bank 2025-11-19 21:03:55 +01:00
imperosol
56c2c2b70e remove Refilling.is_validated 2025-11-19 21:03:55 +01:00
imperosol
78fe4e52ca make Selling.payment_method a SmallIntegerField 2025-11-19 21:03:55 +01:00
imperosol
2a5893aa79 remove Selling.is_validated 2025-11-19 21:03:55 +01:00
36 changed files with 775 additions and 896 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,6 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester
@@ -552,10 +551,3 @@ def test_allow_fragment_mixin():
assert not TestAllowFragmentView.as_view()(request)
request.headers = {"HX-Request": True, **base_headers}
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

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

View File

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

View File

@@ -22,49 +22,106 @@
#
#
import json
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import F
from django.contrib.auth.decorators import login_required
from django.core import serializers
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import ListView, TemplateView
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.utils import html
from django.utils.text import slugify
from django.views.generic import ListView
from haystack.query import SearchQuerySet
from club.models import Club
from core.models import Notification, User
from core.schemas import UserFilterSchema
class NotificationList(LoginRequiredMixin, ListView):
def index(request, context=None):
from com.views import NewsListView
return NewsListView.as_view()(request)
class NotificationList(ListView):
model = Notification
template_name = "core/notification_list.jinja"
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:
self.request.user.notifications.filter(viewed=False).update(viewed=True)
return self.request.user.notifications.order_by("-date")[:20]
def notification(request: HttpRequest, notif_id: int):
notif = get_object_or_404(Notification, id=notif_id)
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
notif.viewed = True
else:
notif.callback()
notif.save()
return redirect(notif.url)
def notification(request, notif_id):
notif = Notification.objects.filter(id=notif_id).first()
if notif:
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
notif.viewed = True
else:
notif.callback()
notif.save()
return redirect(notif.url)
return redirect("/")
class SearchView(LoginRequiredMixin, TemplateView):
template_name = "core/search.jinja"
def search_user(query):
try:
# 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):
users, clubs = [], []
if query := self.request.GET.get("query"):
users = list(
UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
)
clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
def search_club(query, *, as_json=False):
clubs = []
if query:
clubs = Club.objects.filter(name__icontains=query).all()
clubs = clubs[:5]
if as_json:
# Re-loads json to avoid double encoding by JsonResponse, but still benefit from serializers
clubs = json.loads(serializers.serialize("json", clubs, fields=("name")))
else:
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,12 +24,6 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
class CounterConfig(AppConfig):
name = "counter"

View File

@@ -1,7 +1,7 @@
import json
import math
import uuid
from datetime import date, datetime, timezone
from datetime import date
from dateutil.relativedelta import relativedelta
from django import forms
@@ -136,7 +136,10 @@ class GetUserForm(forms.Form):
class RefillForm(forms.ModelForm):
allowed_refilling_methods = ["CASH", "CARD"]
allowed_refilling_methods = [
Refilling.PaymentMethod.CASH,
Refilling.PaymentMethod.CARD,
]
error_css_class = "error"
required_css_class = "required"
@@ -146,7 +149,7 @@ class RefillForm(forms.ModelForm):
class Meta:
model = Refilling
fields = ["amount", "payment_method", "bank"]
fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs):
@@ -160,9 +163,6 @@ class RefillForm(forms.ModelForm):
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
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 Meta:
@@ -235,19 +235,6 @@ class ScheduledProductActionForm(forms.ModelForm):
)
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):
def __init__(self, *args, product: Product, **kwargs):
@@ -334,19 +321,11 @@ class ProductForm(forms.ModelForm):
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs)
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)
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
self.action_formset.save()
return product
return ret
class ReturnableProductForm(forms.ModelForm):
@@ -390,6 +369,7 @@ class EticketForm(forms.ModelForm):
class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField(
label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
@@ -509,14 +489,13 @@ class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
month_start = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
self.clubs = list(
Club.objects.filter(
Exists(
Selling.objects.filter(
club=OuterRef("pk"),
date__gte=month_start,
date__lte=month_start + relativedelta(months=1),
date__gte=month,
date__lte=month + relativedelta(months=1),
)
)
).annotate(

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,12 +11,8 @@ from model_bakery import baker
from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import (
ProductForm,
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
from counter.models import ScheduledProductAction
@pytest.mark.django_db
@@ -38,39 +34,6 @@ def test_edit_product(client: Client):
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
class TestProductActionForm:
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):
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
baker.make(Refilling, amount=amount, customer=user.customer)
class TestFullClickBase(TestCase):
@@ -115,18 +115,10 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse:
used_client = client if client is not None else self.client
return used_client.post(
reverse(
"counter:refilling_create",
kwargs={"customer_id": user.pk},
),
{
"amount": str(amount),
"payment_method": "CASH",
"bank": "OTHER",
},
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
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}
),
)
@@ -149,11 +141,7 @@ class TestRefilling(TestFullClickBase):
"counter:refilling_create",
kwargs={"customer_id": self.customer.pk},
),
{
"amount": "10",
"payment_method": "CASH",
"bank": "OTHER",
},
{"amount": "10", "payment_method": "CASH"},
)
self.client.force_login(self.club_admin)

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import datetime, timezone
from datetime import datetime
from urllib.parse import urlencode
from dateutil.relativedelta import relativedelta
@@ -63,20 +63,17 @@ class InvoiceCallView(
"""Add sums to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
month = self.get_month()
start_date = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
start_date = self.get_month()
end_date = start_date + relativedelta(months=1)
kwargs["sum_cb"] = Refilling.objects.filter(
payment_method="CARD",
is_validated=True,
payment_method=Refilling.PaymentMethod.CARD,
date__gte=start_date,
date__lte=end_date,
).aggregate(res=Sum("amount", default=0))["res"]
kwargs["sum_cb"] += (
Selling.objects.filter(
payment_method="CARD",
is_validated=True,
payment_method=Selling.PaymentMethod.CARD,
date__gte=start_date,
date__lte=end_date,
)

View File

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

View File

@@ -108,12 +108,22 @@ def test_eboutic_basket_expiry(
client.force_login(customer.user)
for date in sellings:
if sellings:
sale_recipe.make(
customer=customer, counter=eboutic, date=date, is_validated=True
customer=customer,
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 (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-12 21:44+0100\n"
"POT-Creation-Date: 2025-11-19 21:00+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -2928,18 +2928,6 @@ msgstr "Photos"
msgid "Account"
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
msgid "counter"
msgstr "comptoir"
@@ -3152,22 +3140,30 @@ msgstr "vendeurs"
msgid "token"
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
msgid "payment method"
msgstr "méthode de paiement"
#: counter/models.py
msgid "bank"
msgstr "banque"
#: counter/models.py
msgid "is validated"
msgstr "est validé"
#: counter/models.py
msgid "refilling"
msgstr "rechargement"
#: counter/models.py
msgid "Sith account"
msgstr "Compte utilisateur"
#: counter/models.py eboutic/models.py
msgid "unit price"
msgstr "prix unitaire"
@@ -3176,10 +3172,6 @@ msgstr "prix unitaire"
msgid "quantity"
msgstr "quantité"
#: counter/models.py
msgid "Sith account"
msgstr "Compte utilisateur"
#: counter/models.py
msgid "selling"
msgstr "vente"
@@ -3332,6 +3324,10 @@ msgid ""
"%(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."
#: counter/models.py
msgid "is validated"
msgstr "est validé"
#: counter/models.py
msgid "invoice date"
msgstr "date de la facture"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -216,7 +216,7 @@ TEMPLATES = [
},
},
]
FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
HAYSTACK_CONNECTIONS = {
"default": {
@@ -440,19 +440,6 @@ SITH_SUBSCRIPTION_LOCATIONS = [
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 = [
("FREE", _("Free")),
("CS", _("CS")),

View File

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

959
uv.lock generated

File diff suppressed because it is too large Load Diff