15 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
thomas girod
7373e3d9de Merge pull request #1254 from ae-utbm/refactor/page-merge
refactor detection of the need to merge `PageRev`
2025-11-19 13:52:52 +01:00
imperosol
3f4a41ba42 refactor detection of the need to merge PageRev 2025-11-19 13:51:38 +01:00
thomas girod
449abbb17e Merge pull request #1248 from ae-utbm/fix/api-barman-auth
fix: user search for anonymous sessions with logged barmen
2025-11-19 13:05:16 +01:00
thomas girod
9862e763ad Merge pull request #1249 from ae-utbm/membership-set-old
prevent csrf on `MembershipSetOldView`
2025-11-19 13:04:51 +01:00
imperosol
32e1f09d46 prevent csrf on MembershipSetOldView 2025-11-16 15:05:10 +01:00
imperosol
f359fab6b4 style: class for <a>-like form submit buttons 2025-11-16 15:04:30 +01:00
imperosol
0b53db7a95 fix: user search for anonymous sessions with logged barmen
Quand une session n'était pas connectée en tant qu'utilisateur, mais avait des utilisateurs connectés en tant que barman, la route de recherche des utilisateurs était 401
2025-11-16 13:31:48 +01:00
imperosol
d325b19383 typo in Sha512ApiKeyHasher docstring 2025-11-16 13:30:17 +01:00
imperosol
33cc9588b0 remove unused Mock 2025-11-16 13:12:58 +01:00
33 changed files with 343 additions and 219 deletions

View File

@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
class Sha512ApiKeyHasher(BasePasswordHasher):
"""
An API key hasher using the sha256 algorithm.
An API key hasher using the sha512 algorithm.
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
It is insecure for use in hashing passwords, but is safe for hashing

View File

@@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker
@@ -532,6 +532,35 @@ class TestMembership(TestClub):
assert new_board == initial_board
@pytest.mark.django_db
def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
client.force_login(membership.user)
response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
membership.refresh_from_db()
assert membership.end_date == localdate()
@pytest.mark.django_db
def test_membership_delete(client: Client):
user = baker.make(User, is_superuser=True)
membership = baker.make(Membership)
client.force_login(user)
url = reverse("club:membership_delete", kwargs={"membership_id": membership.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(url)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)

View File

@@ -34,7 +34,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum
from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
@@ -43,6 +43,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import (
@@ -544,33 +545,17 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView):
"""Set a membership as beeing old."""
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as being old."""
model = Membership
pk_url_kwarg = "membership_id"
def get(self, request, *args, **kwargs):
def post(self, *_args, **_kwargs):
self.object = self.get_object()
self.object.end_date = timezone.now()
self.object.save()
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
return redirect("core:user_clubs", user_id=self.object.user_id)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
@@ -582,7 +567,7 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "club.delete_membership"
def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id})
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):

View File

@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
@@ -18,16 +17,6 @@ from core.markdown import markdown
from core.models import User
@dataclass
class MockResponse:
ok: bool
value: str
@property
def content(self):
return self.value.encode("utf8")
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Any, Literal
import annotated_types
from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
@@ -28,6 +28,7 @@ from core.schemas import (
UserSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown")
@@ -72,7 +73,7 @@ class MailingListController(ControllerBase):
@api_controller("/user")
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
@route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
@@ -85,15 +86,18 @@ class UserController(ControllerBase):
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
permissions=[CanAccessLookup],
# logged in barmen aren't authenticated stricto sensu, so no auth here
auth=None,
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(
User.objects.viewable_by(self.context.request.user).order_by(
F("last_login").desc(nulls_last=True)
)
)
qs = User.objects
# the logged in barmen can see all users (even the hidden one),
# because they have a temporary administrative function during
# which they may have to deal with hidden users
if not is_logged_in_counter(self.context.request):
qs = qs.viewable_by(self.context.request.user)
return filters.filter(qs.order_by(F("last_login").desc(nulls_last=True)))
@api_controller("/file")
@@ -105,7 +109,7 @@ class SithFileController(ControllerBase):
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@@ -118,11 +122,11 @@ class GroupController(ControllerBase):
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DepthValue = Annotated[int, Ge(0), Le(10)]
DEFAULT_DEPTH = 4

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

@@ -23,12 +23,13 @@
#
from __future__ import annotations
import difflib
import string
import unicodedata
from datetime import timedelta
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Final, Self
from uuid import uuid4
from django.conf import settings
@@ -1344,6 +1345,9 @@ class PageRev(models.Model):
The content is in PageRev.title and PageRev.content .
"""
MERGE_TIME_THRESHOLD: Final[timedelta] = timedelta(minutes=20)
MERGE_DIFF_THRESHOLD: Final[float] = 0.2
revision = models.IntegerField(_("revision"))
title = models.CharField(_("page title"), max_length=255, blank=True)
content = models.TextField(_("page content"), blank=True)
@@ -1385,6 +1389,32 @@ class PageRev(models.Model):
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.
The result is a float in [0; 1], 0 meaning the contents are entirely different,
and 1 they are strictly the same.
"""
# cf. https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
return difflib.SequenceMatcher(None, self.content, text).quick_ratio()
def should_merge(self, other: Self) -> bool:
"""Return True if `other` should be merged into `self`, else False.
It's considered the other revision should be merged into this one if :
- it was made less than 20 minutes after
- by the same author
- with a similarity ratio higher than 80%
"""
return (
not self._state.adding # cannot merge if the original rev doesn't exist
and self.author == other.author
and (other.date - self.date) < self.MERGE_TIME_THRESHOLD
and (not other._state.adding or other.revision == self.revision + 1)
and self.similarity_ratio(other.content) >= (1 - other.MERGE_DIFF_THRESHOLD)
)
def get_notification_types():
return settings.SITH_NOTIFICATIONS

View File

@@ -21,6 +21,8 @@ $secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);

View File

@@ -745,4 +745,32 @@ form {
background-repeat: no-repeat;
background-size: var(--nf-input-size);
}
&.no-margin {
margin:0;
}
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&.link-red {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
}
font-weight: normal;
font-size: 100%;
margin: auto;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
}

View File

@@ -5,9 +5,6 @@ $text-color: white;
$background-color-hovered: #283747;
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
.header {
box-sizing: border-box;
background-color: $deepblue;
@@ -251,12 +248,15 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-start;
}
a {
color: $text-color;
}
a,
button {
font-size: 100%;
margin: 0;
text-align: right;
color: $text-color;
margin-top: auto;
&:hover {
@@ -268,19 +268,6 @@ $hovered-red-text-color: #ff4d4d;
margin: 0;
display: inline;
}
#logout-form button {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
}
}

View File

@@ -519,7 +519,6 @@ th {
td {
margin: 5px;
border-collapse: collapse;
vertical-align: top;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -61,7 +61,9 @@
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<form id="logout-form" method="post" action="{{ url("core:logout") }}">
{% csrf_token %}
<button type="submit">{% trans %}Logout{% endtrans %}</button>
<button type="submit" class="link-like link-red">
{% trans %}Logout{% endtrans %}
</button>
</form>
</div>
</div>

View File

@@ -17,7 +17,9 @@
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td>
<td></td>
<td></td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr>
</thead>
<tbody>
@@ -28,7 +30,16 @@
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %}
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
<td>
<form
method="post"
action="{{ url('club:membership_set_old', membership_id=m.id) }}"
class="no-margin"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Mark as old{% endtrans %}" />
</form>
</td>
{% endif %}
{% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
@@ -48,7 +59,9 @@
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr>
</thead>
<tbody>

View File

@@ -1,4 +1,5 @@
from datetime import timedelta
from unittest import mock
import pytest
from django.conf import settings
@@ -23,6 +24,7 @@ from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem
@@ -60,7 +62,9 @@ class TestSearchUsersAPI(TestSearchUsers):
"""Test that users are ordered by last login date."""
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("api:search_users") + "?search=First")
response = self.client.get(
reverse("api:search_users", query={"search": "First"})
)
assert response.status_code == 200
assert response.json()["count"] == 11
# The users are ordered by last login date, so we need to reverse the list
@@ -69,7 +73,7 @@ class TestSearchUsersAPI(TestSearchUsers):
]
def test_search_case_insensitive(self):
"""Test that the search is case insensitive."""
"""Test that the search is case-insensitive."""
self.client.force_login(subscriber_user.make())
expected = [u.id for u in self.users[::-1]]
@@ -82,14 +86,19 @@ class TestSearchUsersAPI(TestSearchUsers):
assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self):
"""Test that the search can be done on the nick name."""
"""Test that the search can be done on the nickname."""
# hidden users should not be in the final result,
# even when the nickname matches
self.users[10].is_viewable = False
self.users[10].save()
self.client.force_login(subscriber_user.make())
# this should return users with nicknames Nick11, Nick10 and Nick1
response = self.client.get(reverse("api:search_users") + "?search=Nick1")
response = self.client.get(
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
@@ -101,10 +110,25 @@ class TestSearchUsersAPI(TestSearchUsers):
self.client.force_login(subscriber_user.make())
# this should return users with first names First1 and First10
response = self.client.get(reverse("api:search_users") + "?search=bél")
response = self.client.get(reverse("api:search_users", query={"search": "bél"}))
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [belix.id]
@mock.create_autospec(is_logged_in_counter, return_value=True)
def test_search_as_barman(self):
# barmen should also see hidden users
self.users[10].is_viewable = False
self.users[10].save()
response = self.client.get(
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
class TestSearchUsersView(TestSearchUsers):
"""Test the search user view (`GET /search`)."""
@@ -163,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

@@ -20,9 +20,9 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import difflib
import re
from datetime import date, datetime, timedelta
from copy import copy
from datetime import date, datetime
from io import BytesIO
from captcha.fields import CaptchaField
@@ -390,14 +390,11 @@ class PageRevisionForm(forms.ModelForm):
- less than 20 minutes ago
- by the same author
- with a diff ratio higher than 20%
- with a similarity ratio higher than 80%
then the latter will be edited and the new revision won't be created.
"""
TIME_THRESHOLD = timedelta(minutes=20)
DIFF_THRESHOLD = 0.2
class Meta:
model = PageRev
fields = ["title", "content"]
@@ -409,21 +406,11 @@ class PageRevisionForm(forms.ModelForm):
super().__init__(*args, instance=instance, **kwargs)
self.author = author
self.page = page
self.initial_content = instance.content if instance else ""
def diff_ratio(self, new_str: str) -> float:
return difflib.SequenceMatcher(
None, self.initial_content, new_str
).quick_ratio()
self.initial_obj: PageRev = copy(self.instance)
def save(self, commit=True): # noqa FBT002
revision: PageRev = self.instance
if (
revision._state.adding
or revision.author != self.author
or revision.date + self.TIME_THRESHOLD < now()
or self.diff_ratio(revision.content) < (1 - self.DIFF_THRESHOLD)
):
if not self.initial_obj.should_merge(self.instance):
revision.author = self.author
revision.page = self.page
revision.id = None # if id is None, Django will create a new record

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

@@ -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:

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

@@ -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

@@ -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

@@ -67,15 +67,13 @@ class InvoiceCallView(
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

@@ -43,7 +43,7 @@ dependencies = [
"tomli>=2.3.0,<3.0.0",
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0",
"ical>=11.1.0,<13",
"ical>=11.1.0,<12",
"redis[hiredis]<7,>=5.3.0",
"environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.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

@@ -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