Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
8bdf7c7eba [UPDATE] Update ical requirement from <12,>=11.1.0 to >=11.1.0,<13
Updates the requirements on [ical](https://github.com/allenporter/ical) to permit the latest version.
- [Release notes](https://github.com/allenporter/ical/releases)
- [Commits](https://github.com/allenporter/ical/compare/11.1.0...12.0.0)

---
updated-dependencies:
- dependency-name: ical
  dependency-version: 12.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-17 08:03:55 +00:00
62 changed files with 1195 additions and 1352 deletions

View File

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

View File

@@ -1,16 +1,18 @@
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 FilterLookup, FilterSchema, ModelSchema from ninja import Field, FilterSchema, ModelSchema
from club.models import Club, Membership from club.models import Club, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema from core.schemas import SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema): 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 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

@@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Max from django.db.models import Max
from django.test import Client, TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from model_bakery import baker from model_bakery import baker
@@ -532,35 +532,6 @@ class TestMembership(TestClub):
assert new_board == initial_board 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 @pytest.mark.django_db
class TestJoinClub: class TestJoinClub:
@pytest.fixture(autouse=True) @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.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum from django.db.models import F, Q, Sum
from django.http import Http404, StreamingHttpResponse from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
@@ -43,7 +43,6 @@ from django.utils.timezone import now
from django.utils.translation import gettext from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View 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 django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import ( from club.forms import (
@@ -545,17 +544,33 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club" permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View): class MembershipSetOldView(CanEditMixin, DetailView):
"""Set a membership as being old.""" """Set a membership as beeing old."""
model = Membership model = Membership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
def post(self, *_args, **_kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
self.object.end_date = timezone.now() self.object.end_date = timezone.now()
self.object.save() self.object.save()
return redirect("core:user_clubs", user_id=self.object.user_id) 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},
)
)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView): class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
@@ -567,7 +582,7 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "club.delete_membership" permission_required = "club.delete_membership"
def get_success_url(self): 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): class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):

View File

@@ -1,9 +1,9 @@
from datetime import datetime 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 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: Annotated[datetime | None, FilterLookup("end_date__lt")] = None before: datetime | None = Field(None, q="end_date__lt")
after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None after: datetime | None = Field(None, q="start_date__gt")
club_id: Annotated[int | None, FilterLookup("news__club_id")] = None club_id: int | None = Field(None, q="news__club_id")
news_id: int | None = None news_id: int | None = None
is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None is_published: bool | None = Field(None, q="news__is_published")
title: Annotated[str | None, FilterLookup("news__title__icontains")] = None title: str | None = Field(None, q="news__title__icontains")
class NewsSchema(ModelSchema): class NewsSchema(ModelSchema):

View File

@@ -211,7 +211,7 @@
</li> </li>
<li> <li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i> <i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a> <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li> </li>
<li> <li>
<i class="fa-solid fa-check-to-slot fa-xl"></i> <i class="fa-solid fa-check-to-slot fa-xl"></i>

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -17,6 +18,16 @@ from core.markdown import markdown
from core.models import User 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: def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", "")) redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem): if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):

View File

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

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

@@ -23,13 +23,12 @@
# #
from __future__ import annotations from __future__ import annotations
import difflib
import string import string
import unicodedata import unicodedata
from datetime import timedelta from datetime import timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Final, Self from typing import TYPE_CHECKING, Self
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
@@ -38,6 +37,7 @@ from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup from django.contrib.auth.models import Group as AuthGroup
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File from django.core.files import File
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@@ -76,6 +76,16 @@ class Group(AuthGroup):
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
return reverse("core:group_list") return reverse("core:group_list")
def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs)
cache.set(f"sith_group_{self.id}", self)
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
def delete(self, *args, **kwargs) -> None:
super().delete(*args, **kwargs)
cache.delete(f"sith_group_{self.id}")
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
def validate_promo(value: int) -> None: def validate_promo(value: int) -> None:
last_promo = get_last_promo() last_promo = get_last_promo()
@@ -1334,9 +1344,6 @@ class PageRev(models.Model):
The content is in PageRev.title and PageRev.content . 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")) revision = models.IntegerField(_("revision"))
title = models.CharField(_("page title"), max_length=255, blank=True) title = models.CharField(_("page title"), max_length=255, blank=True)
content = models.TextField(_("page content"), blank=True) content = models.TextField(_("page content"), blank=True)
@@ -1378,32 +1385,6 @@ class PageRev(models.Model):
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups) 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(): def get_notification_types():
return settings.SITH_NOTIFICATIONS return settings.SITH_NOTIFICATIONS

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
@@ -9,14 +8,12 @@ from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field, field_validator from pydantic import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo 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 get_last_promo, is_image from core.utils import is_image
NonEmptyStr = Annotated[str, MinLen(1)]
class UploadedImage(UploadedFile): class UploadedImage(UploadedFile):
@@ -110,11 +107,7 @@ class GroupSchema(ModelSchema):
class UserFilterSchema(FilterSchema): class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = None search: Annotated[str, MinLen(1)]
role: Annotated[str, FilterLookup("role__icontains")] | None = None
department: str | None = None
promo: int | None = None
date_of_birth: datetime | None = None
exclude: list[int] | None = Field( exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]") None, validation_alias=AliasChoices("exclude", "exclude[]")
) )
@@ -143,13 +136,6 @@ class UserFilterSchema(FilterSchema):
return Q() return Q()
return ~Q(id__in=value) return ~Q(id__in=value)
@field_validator("promo", mode="after")
@classmethod
def validate_promo(cls, value: int) -> int:
if not 0 < value <= get_last_promo():
raise ValueError(f"{value} is not a valid promo")
return value
class MarkdownSchema(Schema): class MarkdownSchema(Schema):
text: str text: str

View File

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

View File

@@ -143,15 +143,6 @@ form {
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
.fields-centered {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
justify-content: center;
}
.helptext { .helptext {
margin-top: .25rem; margin-top: .25rem;
margin-bottom: .25rem; margin-bottom: .25rem;
@@ -754,32 +745,4 @@ form {
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: var(--nf-input-size); 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,6 +5,9 @@ $text-color: white;
$background-color-hovered: #283747; $background-color-hovered: #283747;
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
.header { .header {
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: $deepblue;
@@ -248,15 +251,12 @@ $background-color-hovered: #283747;
justify-content: flex-start; justify-content: flex-start;
} }
a {
color: $text-color;
}
a, a,
button { button {
font-size: 100%; font-size: 100%;
margin: 0; margin: 0;
text-align: right; text-align: right;
color: $text-color;
margin-top: auto; margin-top: auto;
&:hover { &:hover {
@@ -268,6 +268,19 @@ $background-color-hovered: #283747;
margin: 0; margin: 0;
display: inline; 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,6 +519,7 @@ th {
td { td {
margin: 5px; margin: 5px;
border-collapse: collapse; border-collapse: collapse;
vertical-align: top;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@@ -114,6 +114,15 @@
} }
} }
&-fields {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
justify-content: center;
}
&-field { &-field {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

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

View File

@@ -23,7 +23,7 @@
<details name="navbar" class="menu"> <details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary> <summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content"> <ul class="content">
<li><a href="{{ url('matmat:search') }}">{% trans %}Matmatronch{% endtrans %}</a></li> <li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li> <li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li> <li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul> </ul>

View File

@@ -157,12 +157,12 @@
{% if current_page.has_previous() %} {% if current_page.has_previous() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}" hx-get="?page={{ current_page.previous_page_number() }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?{{ querystring(page=current_page.previous_page_number()) }}" href="?page={{ current_page.previous_page_number() }}"
{%- endif -%} {%- endif -%}
> >
<button> <button>
@@ -180,12 +180,12 @@
{% else %} {% else %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?{{ querystring(page=i) }}" hx-get="?page={{ i }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?{{ querystring(page=i) }}" href="?page={{ i }}"
{%- endif -%} {%- endif -%}
> >
<button>{{ i }}</button> <button>{{ i }}</button>
@@ -195,12 +195,12 @@
{% if current_page.has_next() %} {% if current_page.has_next() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?querystring(page=current_page.next_page_number())" hx-get="?page={{ current_page.next_page_number() }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?querystring(page=current_page.next_page_number())" href="?page={{ current_page.next_page_number() }}"
{%- endif -%} {%- endif -%}
><button> ><button>
<i class="fa fa-caret-right"></i> <i class="fa fa-caret-right"></i>
@@ -249,17 +249,3 @@
}"></div> }"></div>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro querystring() %}
{%- for key, values in request.GET.lists() -%}
{%- if key not in kwargs -%}
{%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %}

View File

@@ -9,17 +9,19 @@
{% block content %} {% block content %}
<h4>{% trans %}Users{% endtrans %}</h4> <h4>{% trans %}Users{% endtrans %}</h4>
<ul> <ul>
{% for user in users %} {% for i in result.users %}
<li> {% if user.can_view(i) %}
{{ user_link_with_pict(user) }} <li>
</li> {{ user_link_with_pict(i) }}
</li>
{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
<h4>{% trans %}Clubs{% endtrans %}</h4> <h4>{% trans %}Clubs{% endtrans %}</h4>
<ul> <ul>
{% for club in clubs %} {% for i in result.clubs %}
<li> <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> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -17,9 +17,7 @@
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td> <td>{% trans %}Since{% endtrans %}</td>
<td></td> <td></td>
{% if user.has_perm("club.delete_membership") %} <td></td>
<td></td>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -30,16 +28,7 @@
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %} {% if m.can_be_edited_by(user) %}
<td> <td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></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 %} {% endif %}
{% if user.has_perm("club.delete_membership") %} {% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td> <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
@@ -59,9 +48,7 @@
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td> <td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td> <td>{% trans %}To{% endtrans %}</td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -114,7 +114,7 @@
{# All fields #} {# All fields #}
<div class="fields-centered"> <div class="profile-fields">
{%- for field in form -%} {%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%} {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- continue -%} {%- continue -%}
@@ -133,7 +133,7 @@
</div> </div>
{# Textareas #} {# Textareas #}
<div class="fields-centered"> <div class="profile-fields">
{%- for field in [form.quote, form.forum_signature] -%} {%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field"> <div class="profile-field">
{{ field.label_tag() }} {{ field.label_tag() }}

View File

@@ -11,35 +11,32 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
{% if total_perm_time %} {% if profile.permanencies %}
<div> <div>
<h3>{% trans %}Permanencies{% endtrans %}</h3> <h3>{% trans %}Permanencies{% endtrans %}</h3>
<div class="flexed"> <div class="flexed">
{% for perm in perm_time %} <div><span>Foyer :</span><span>{{ total_foyer_time }}</span></div>
<div> <div><span>Gommette :</span><span>{{ total_gommette_time }}</span></div>
<span>{{ perm["counter__name"] }} :</span> <div><span>MDE :</span><span>{{ total_mde_time }}</span></div>
<span>{{ perm["total"]|format_timedelta }}</span> <div><b>Total :</b><b>{{ total_perm_time }}</b></div>
</div>
{% endfor %}
<div><b>Total :</b><b>{{ total_perm_time|format_timedelta }}</b></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div> <div>
<h3>{% trans %}Buyings{% endtrans %}</h3> <h3>{% trans %}Buyings{% endtrans %}</h3>
<div class="flexed"> <div class="flexed">
{% for sum in purchase_sums %} <div><span>Foyer :</span><span>{{ total_foyer_buyings }}&nbsp;€</span></div>
<div> <div><span>Gommette :</span><span>{{ total_gommette_buyings }}&nbsp;€</span></div>
<span>{{ sum["counter__name"] }}</span> <div><span>MDE :</span><span>{{ total_mde_buyings }}&nbsp;€</span></div>
<span>{{ sum["total"] }} €</span> <div><b>Total :</b><b>{{ total_foyer_buyings + total_gommette_buyings + total_mde_buyings }}&nbsp;€</b>
</div> </div>
{% endfor %}
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<h3>{% trans %}Product top 15{% endtrans %}</h3> <h3>{% trans %}Product top 10{% endtrans %}</h3>
<table> <table>
<thead> <thead>
<tr> <tr>

View File

@@ -55,17 +55,31 @@ def phonenumber(
return value return value
@register.filter(name="truncate_time")
def truncate_time(value, time_unit):
"""Remove everything in the time format lower than the specified unit.
Args:
value: the value to truncate
time_unit: the lowest unit to display
"""
value = str(value)
return {
"millis": lambda: value.split(".")[0],
"seconds": lambda: value.rsplit(":", maxsplit=1)[0],
"minutes": lambda: value.split(":", maxsplit=1)[0],
"hours": lambda: value.rsplit(" ")[0],
}[time_unit]()
@register.filter(name="format_timedelta") @register.filter(name="format_timedelta")
def format_timedelta(value: datetime.timedelta) -> str: def format_timedelta(value: datetime.timedelta) -> str:
value = value - datetime.timedelta(microseconds=value.microseconds)
days = value.days days = value.days
if days == 0: if days == 0:
return str(value) return str(value)
remainder = value - datetime.timedelta(days=days) remainder = value - datetime.timedelta(days=days)
return ngettext( return ngettext(
"%(nb_days)d day, %(remainder)s", "%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
"%(nb_days)d days, %(remainder)s",
days,
) % {"nb_days": days, "remainder": str(remainder)} ) % {"nb_days": days, "remainder": str(remainder)}

View File

@@ -35,7 +35,6 @@ 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
@@ -552,10 +551,3 @@ 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

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

@@ -1,6 +1,4 @@
import itertools
from datetime import timedelta from datetime import timedelta
from unittest import mock
import pytest import pytest
from django.conf import settings from django.conf import settings
@@ -24,8 +22,7 @@ from core.baker_recipes import (
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling from counter.models import Counter, Customer, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@@ -63,9 +60,7 @@ class TestSearchUsersAPI(TestSearchUsers):
"""Test that users are ordered by last login date.""" """Test that users are ordered by last login date."""
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
response = self.client.get( response = self.client.get(reverse("api:search_users") + "?search=First")
reverse("api:search_users", query={"search": "First"})
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["count"] == 11 assert response.json()["count"] == 11
# The users are ordered by last login date, so we need to reverse the list # The users are ordered by last login date, so we need to reverse the list
@@ -74,7 +69,7 @@ class TestSearchUsersAPI(TestSearchUsers):
] ]
def test_search_case_insensitive(self): 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()) self.client.force_login(subscriber_user.make())
expected = [u.id for u in self.users[::-1]] expected = [u.id for u in self.users[::-1]]
@@ -87,19 +82,14 @@ class TestSearchUsersAPI(TestSearchUsers):
assert [r["id"] for r in response.json()["results"]] == expected assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self): def test_search_nick_name(self):
"""Test that the search can be done on the nickname.""" """Test that the search can be done on the nick name."""
# 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()) self.client.force_login(subscriber_user.make())
# this should return users with nicknames Nick11, Nick10 and Nick1 # this should return users with nicknames Nick11, Nick10 and Nick1
response = self.client.get( response = self.client.get(reverse("api:search_users") + "?search=Nick1")
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200 assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [ assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id, self.users[9].id,
self.users[0].id, self.users[0].id,
] ]
@@ -111,25 +101,10 @@ class TestSearchUsersAPI(TestSearchUsers):
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
# this should return users with first names First1 and First10 # this should return users with first names First1 and First10
response = self.client.get(reverse("api:search_users", query={"search": "bél"})) response = self.client.get(reverse("api:search_users") + "?search=bél")
assert response.status_code == 200 assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [belix.id] 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): class TestSearchUsersView(TestSearchUsers):
"""Test the search user view (`GET /search`).""" """Test the search user view (`GET /search`)."""
@@ -188,7 +163,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 = [
@@ -425,28 +404,3 @@ class TestUserQuerySetViewableBy:
user = user_factory() user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists() assert not viewable.exists()
@pytest.mark.django_db
def test_user_stats(client: Client):
user = subscriber_user.make()
baker.make(Refilling, customer=user.customer, amount=99999)
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make(
Permanency,
end=now() - timedelta(days=5),
start=now() - timedelta(days=5, hours=3),
counter_id=itertools.cycle(bars),
_quantity=5,
_bulk_create=True,
)
sale_recipe.make(
counter_id=itertools.cycle(bars),
customer=user.customer,
unit_price=1,
quantity=1,
_quantity=5,
)
client.force_login(user)
response = client.get(reverse("core:user_stats", kwargs={"user_id": user.id}))
assert response.status_code == 200

View File

@@ -24,7 +24,6 @@
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,
@@ -54,7 +53,6 @@ from core.views import (
PagePropView, PagePropView,
PageRevView, PageRevView,
PageView, PageView,
SearchView,
SithLoginView, SithLoginView,
SithPasswordChangeDoneView, SithPasswordChangeDoneView,
SithPasswordChangeView, SithPasswordChangeView,
@@ -78,9 +76,13 @@ 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,
) )
@@ -89,11 +91,13 @@ register_converter(TwoDigitMonthConverter, "mm")
register_converter(BooleanStringConverter, "bool") register_converter(BooleanStringConverter, "bool")
urlpatterns = [ urlpatterns = [
path("", NewsListView.as_view(), name="index"), path("", index, 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/", 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 # 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

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

View File

@@ -22,49 +22,106 @@
# #
# #
import json
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.decorators import login_required
from django.db.models import F from django.core import serializers
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import HttpRequest from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import redirect, render
from django.views.generic import ListView, TemplateView 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 club.models import Club
from core.models import Notification, User 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 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: HttpRequest, notif_id: int): def notification(request, notif_id):
notif = get_object_or_404(Notification, id=notif_id) notif = Notification.objects.filter(id=notif_id).first()
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS: if notif:
notif.viewed = True if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
else: notif.viewed = True
notif.callback() else:
notif.save() notif.callback()
return redirect(notif.url) notif.save()
return redirect(notif.url)
return redirect("/")
class SearchView(LoginRequiredMixin, TemplateView): def search_user(query):
template_name = "core/search.jinja" 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 = [], [] def search_club(query, *, as_json=False):
if query := self.request.GET.get("query"): clubs = []
users = list( if query:
UserFilterSchema(search=query) clubs = Club.objects.filter(name__icontains=query).all()
.filter(User.objects.viewable_by(self.request.user)) clubs = clubs[:5]
.order_by(F("last_login").desc(nulls_last=True)) if as_json:
) # Re-loads json to avoid double encoding by JsonResponse, but still benefit from serializers
clubs = list(Club.objects.filter(name__icontains=query)[:5]) clubs = json.loads(serializers.serialize("json", clubs, fields=("name")))
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs} 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

@@ -22,9 +22,9 @@
# #
# #
import itertools import itertools
from datetime import timedelta
# This file contains all the views that concern the user model # This file contains all the views that concern the user model
from datetime import date, timedelta
from operator import itemgetter from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
@@ -32,7 +32,7 @@ from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import DateField, F, QuerySet, Sum from django.db.models import DateField, QuerySet
from django.db.models.functions import Trunc from django.db.models.functions import Trunc
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import Http404 from django.http import Http404
@@ -66,8 +66,9 @@ from core.views.forms import (
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import TabedViewMixin, UseFragmentsMixin from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from counter.models import Refilling, Selling from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from subscription.models import Subscription
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@@ -352,40 +353,87 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
context_object_name = "profile" context_object_name = "profile"
template_name = "core/user_stats.jinja" template_name = "core/user_stats.jinja"
current_tab = "stats" current_tab = "stats"
queryset = User.objects.exclude(customer=None).select_related("customer")
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
profile = self.get_object() profile = self.get_object()
if not hasattr(profile, "customer"):
raise Http404
if not ( if not (
profile == request.user or request.user.has_perm("counter.view_customer") profile == request.user or request.user.has_perm("counter.view_customer")
): ):
raise PermissionDenied raise PermissionDenied
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
from django.db.models import Sum
kwargs["perm_time"] = list( foyer = Counter.objects.filter(name="Foyer").first()
self.object.permanencies.filter(end__isnull=False, counter__type="BAR") mde = Counter.objects.filter(name="MDE").first()
.values("counter", "counter__name") gommette = Counter.objects.filter(name="La Gommette").first()
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0))) semester_start = Subscription.compute_start(d=date.today(), duration=3)
.order_by("-total")
)
kwargs["total_perm_time"] = sum( kwargs["total_perm_time"] = sum(
[perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0) [p.end - p.start for p in self.object.permanencies.exclude(end=None)],
timedelta(),
) )
kwargs["purchase_sums"] = list( kwargs["total_foyer_time"] = sum(
self.object.customer.buyings.filter(counter__type="BAR") [
.values("counter", "counter__name") p.end - p.start
.annotate(total=Sum(F("unit_price") * F("quantity"))) for p in self.object.permanencies.filter(counter=foyer).exclude(
.order_by("-total") end=None
)
],
timedelta(),
)
kwargs["total_mde_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=mde).exclude(end=None)
],
timedelta(),
)
kwargs["total_gommette_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=gommette).exclude(
end=None
)
],
timedelta(),
)
kwargs["total_foyer_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=foyer, date__gte=semester_start
)
]
)
kwargs["total_mde_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=mde, date__gte=semester_start
)
]
)
kwargs["total_gommette_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=gommette, date__gte=semester_start
)
]
) )
kwargs["total_purchases"] = sum(s["total"] for s in kwargs["purchase_sums"])
kwargs["top_product"] = ( kwargs["top_product"] = (
self.object.customer.buyings.values("product__name") self.object.customer.buyings.values("product__name")
.annotate(product_sum=Sum("quantity")) .annotate(product_sum=Sum("quantity"))
.exclude(product_sum=None)
.order_by("-product_sum") .order_by("-product_sum")
.all()[:15] .all()[:10]
) )
return kwargs return kwargs

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, datetime, timezone from datetime import date
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,19 +235,6 @@ 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):
@@ -334,19 +321,11 @@ 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) -> Product: def save(self, *args, **kwargs):
product = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"]) self.instance.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 product return ret
class ReturnableProductForm(forms.ModelForm): class ReturnableProductForm(forms.ModelForm):
@@ -390,6 +369,7 @@ 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(),
@@ -509,14 +489,13 @@ class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs): def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.month = month self.month = month
month_start = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
self.clubs = list( self.clubs = list(
Club.objects.filter( Club.objects.filter(
Exists( Exists(
Selling.objects.filter( Selling.objects.filter(
club=OuterRef("pk"), club=OuterRef("pk"),
date__gte=month_start, date__gte=month,
date__lte=month_start + relativedelta(months=1), date__lte=month + relativedelta(months=1),
) )
) )
).annotate( ).annotate(

View File

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

View File

@@ -51,7 +51,7 @@
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td> <td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td>
<td>{{ barman.promo or '' }}</td> <td>{{ barman.promo or '' }}</td>
<td>{{ barman.perm_sum|format_timedelta }}</td> <td>{{ barman.perm_sum|format_timedelta|truncate_time("millis") }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -73,7 +73,7 @@
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td> <td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td>
<td>{{ barman.promo or '' }}</td> <td>{{ barman.promo or '' }}</td>
<td>{{ barman.perm_sum|format_timedelta }}</td> <td>{{ barman.perm_sum|format_timedelta|truncate_time("millis") }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

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,12 +11,8 @@ 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 ( from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
ProductForm, from counter.models import ScheduledProductAction
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
@pytest.mark.django_db @pytest.mark.django_db
@@ -38,39 +34,6 @@ 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 now from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -57,7 +57,7 @@ def test_invoice_call_view(client: Client, query: dict | None):
@pytest.mark.django_db @pytest.mark.django_db
def test_invoice_call_form(): def test_invoice_call_form():
Selling.objects.all().delete() Selling.objects.all().delete()
month = now() - relativedelta(months=1) month = localdate() - relativedelta(months=1)
clubs = baker.make(Club, _quantity=2) clubs = baker.make(Club, _quantity=2)
recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000)) recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000))
recipe.make(club=clubs[0], quantity=2, unit_price=200) recipe.make(club=clubs[0], quantity=2, unit_price=200)

View File

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

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import datetime, timezone from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -63,18 +63,19 @@ class InvoiceCallView(
"""Add sums to the context.""" """Add sums to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC") kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
month = self.get_month() start_date = self.get_month()
start_date = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
end_date = start_date + relativedelta(months=1) end_date = start_date + relativedelta(months=1)
kwargs["sum_cb"] = Refilling.objects.filter( kwargs["sum_cb"] = Refilling.objects.filter(
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-30 18:23+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"
@@ -2658,8 +2658,8 @@ msgid "Buyings"
msgstr "Achats" msgstr "Achats"
#: core/templates/core/user_stats.jinja #: core/templates/core/user_stats.jinja
msgid "Product top 15" msgid "Product top 10"
msgstr "Top 15 produits" msgstr "Top 10 produits"
#: core/templates/core/user_stats.jinja #: core/templates/core/user_stats.jinja
msgid "Product" msgid "Product"
@@ -2819,8 +2819,8 @@ msgstr "Outils Trombi"
#, python-format #, python-format
msgid "%(nb_days)d day, %(remainder)s" msgid "%(nb_days)d day, %(remainder)s"
msgid_plural "%(nb_days)d days, %(remainder)s" msgid_plural "%(nb_days)d days, %(remainder)s"
msgstr[0] "%(nb_days)d jour, %(remainder)s" msgstr[0] ""
msgstr[1] "%(nb_days)d jours, %(remainder)s" msgstr[1] ""
#: core/views/files.py #: core/views/files.py
msgid "Add a new folder" msgid "Add a new folder"
@@ -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"
@@ -4384,10 +4388,6 @@ msgstr "Galaxie de %(user_name)s"
msgid "This citizen has not yet joined the galaxy" msgid "This citizen has not yet joined the galaxy"
msgstr "Ce citoyen n'a pas encore rejoint la galaxie" msgstr "Ce citoyen n'a pas encore rejoint la galaxie"
#: matmat/forms.py
msgid "Last/First name or nickname"
msgstr "Nom de famille, prénom ou surnom"
#: matmat/templates/matmat/search_form.jinja #: matmat/templates/matmat/search_form.jinja
msgid "Search user" msgid "Search user"
msgstr "Rechercher un utilisateur" msgstr "Rechercher un utilisateur"
@@ -4396,6 +4396,22 @@ msgstr "Rechercher un utilisateur"
msgid "Results" msgid "Results"
msgstr "Résultats" msgstr "Résultats"
#: matmat/templates/matmat/search_form.jinja
msgid "Search by profile"
msgstr "Recherche par profil"
#: matmat/templates/matmat/search_form.jinja
msgid "Inverted search"
msgstr "Recherche inversée"
#: matmat/templates/matmat/search_form.jinja
msgid "Quick search"
msgstr "Recherche rapide"
#: matmat/views.py
msgid "Last/First name or nickname"
msgstr "Nom de famille, prénom ou surnom"
#: pedagogy/forms.py #: pedagogy/forms.py
msgid "Do not vote" msgid "Do not vote"
msgstr "Ne pas voter" msgstr "Ne pas voter"

View File

@@ -1,48 +0,0 @@
#
# Copyright 2025
# - Maréchal <thomas.girod@utbm.fr>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from typing import Any
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.forms import SelectDate
class SearchForm(forms.ModelForm):
class Meta:
model = User
fields = ["promo", "role", "department", "semester", "date_of_birth"]
widgets = {"date_of_birth": SelectDate}
name = forms.CharField(
label=_("Last/First name or nickname"), min_length=1, max_length=255
)
field_order = ["name", "promo", "role", "department", "semester", "date_of_birth"]
def __init__(self, *args, initial: dict[str, Any], **kwargs):
super().__init__(*args, initial=initial, **kwargs)
for key in self.fields:
self.fields[key].required = False
if key not in initial:
self.fields[key].initial = None

View File

@@ -1,13 +1,12 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja %} {% from "core/macros.jinja" import user_mini_profile, paginate_jinja %}
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja with context %}
{% block title %} {% block title %}
{% trans %}Search user{% endtrans %} {% trans %}Search user{% endtrans %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if paginator.count > 0 %} {% if result_exists %}
<h2>{% trans %}Results{% endtrans %}</h2> <h2>{% trans %}Results{% endtrans %}</h2>
<div class="matmat_results"> <div class="matmat_results">
{% for user in object_list %} {% for user in object_list %}
@@ -25,12 +24,42 @@
<hr> <hr>
{% endif %} {% endif %}
<h2>{% trans %}Search user{% endtrans %}</h2> <h2>{% trans %}Search user{% endtrans %}</h2>
<form action="{{ url('matmat:search') }}" method="get"> <h3>{% trans %}Search by profile{% endtrans %}</h3>
<fieldset class="fields-centered"> <form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data">
{{ form }} {% csrf_token %}
</fieldset> {% for field in form %}
<div class="fields-centered"> {% if field.name not in ('phone', 'quick') %}
<input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" /> <p>
</div> {{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
</p>
{% endif %}
{% endfor %}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</form>
<h3>{% trans %}Inverted search{% endtrans %}</h3>
<form action="{{ url('matmat:search_reverse') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.phone.errors }}
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label>
{{ form.phone }}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</p>
</form>
<h3>{% trans %}Quick search{% endtrans %}</h3>
<form action="{{ url('matmat:search_quick') }}" method="post">
{% csrf_token %}
<p>
{{ form.quick.errors }}
<label for="{{ form.quick.id_for_label }}">{{ form.quick.label }}</label>
{{ form.quick }}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</p>
</form> </form>
{% endblock %} {% endblock %}
{% block script %}
{{ super() }}
{% endblock %}

View File

@@ -1,35 +1 @@
# Create your tests here. # Create your tests here.
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from com.models import News
from core.baker_recipes import subscriber_user
from core.models import User
class TestMatmatronch(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
User.objects.all().delete()
users = [
baker.prepare(User, promo=17),
baker.prepare(User, promo=17),
baker.prepare(User, promo=17, department="INFO"),
baker.prepare(User, promo=18, department="INFO"),
]
cls.users = User.objects.bulk_create(users)
call_command("update_index", "core", "--remove")
def test_search(self):
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("matmat:search"))
assert response.status_code == 200
response = self.client.get(
reverse("matmat:search", query={"promo": 17, "department": "INFO"})
)
assert response.status_code == 200
assert list(response.context_data["object_list"]) == [self.users[2]]

View File

@@ -23,8 +23,16 @@
from django.urls import path from django.urls import path
from matmat.views import MatmatronchView from matmat.views import (
SearchClearFormView,
SearchNormalFormView,
SearchQuickFormView,
SearchReverseFormView,
)
urlpatterns = [ urlpatterns = [
path("", MatmatronchView.as_view(), name="search"), path("", SearchNormalFormView.as_view(), name="search"),
path("reverse/", SearchReverseFormView.as_view(), name="search_reverse"),
path("quick/", SearchQuickFormView.as_view(), name="search_quick"),
path("clear/", SearchClearFormView.as_view(), name="search_clear"),
] ]

View File

@@ -20,44 +20,188 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from ast import literal_eval
from enum import Enum
from django.db.models import F from django import forms
from django.views.generic import ListView from django.http.response import HttpResponseRedirect
from django.views.generic.edit import FormMixin from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from core.auth.mixins import FormerSubscriberMixin from core.auth.mixins import FormerSubscriberMixin
from core.models import User, UserQuerySet from core.models import User
from core.schemas import UserFilterSchema from core.views import search_user
from matmat.forms import SearchForm from core.views.forms import SelectDate
# Enum to select search type
class MatmatronchView(FormerSubscriberMixin, FormMixin, ListView): class SearchType(Enum):
NORMAL = 1
REVERSE = 2
QUICK = 3
# Custom form
class SearchForm(forms.ModelForm):
class Meta:
model = User
fields = [
"first_name",
"last_name",
"nick_name",
"role",
"department",
"semester",
"promo",
"date_of_birth",
"phone",
]
widgets = {
"date_of_birth": SelectDate,
"phone": RegionalPhoneNumberWidget,
}
quick = forms.CharField(label=_("Last/First name or nickname"), max_length=255)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for key in self.fields:
self.fields[key].required = False
@property
def cleaned_data_json(self):
data = self.cleaned_data
for key, val in data.items():
if key in ("date_of_birth", "phone") and val is not None:
data[key] = str(val)
return data
# Views
class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
model = User model = User
paginate_by = 20 ordering = ["-id"]
paginate_by = 12
template_name = "matmat/search_form.jinja" template_name = "matmat/search_form.jinja"
form_class = SearchForm
def get(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.form = self.get_form() self.form_class = kwargs["form"]
return super().get(request, *args, **kwargs) self.search_type = kwargs["search_type"]
self.session = request.session
self.last_search = self.session.get("matmat_search_result", str([]))
self.last_search = literal_eval(self.last_search)
self.valid_form = kwargs.get("valid_form")
def get_initial(self): self.init_query = self.model.objects
return self.request.GET self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False
self.init_query = self.init_query.filter(is_viewable=True)
def get_form_kwargs(self): return super().dispatch(request, *args, **kwargs)
res = super().get_form_kwargs()
if self.request.GET:
res["data"] = self.request.GET
return res
def get_queryset(self) -> UserQuerySet: def post(self, request, *args, **kwargs):
if not self.form.is_valid(): return self.get(request, *args, **kwargs)
return User.objects.none()
data = self.form.cleaned_data
data["search"] = data.get("name")
filters = UserFilterSchema(**{key: val for key, val in data.items() if val})
qs = User.objects.viewable_by(self.request.user).select_related("profile_pict")
return filters.filter(qs).order_by(F("last_login").desc(nulls_last=True))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(form=self.form, **kwargs) self.object = None
kwargs = super().get_context_data(**kwargs)
kwargs["form"] = self.form_class
kwargs["result_exists"] = self.result_exists
return kwargs
def get_queryset(self):
q = self.init_query
if self.valid_form is not None:
if self.search_type == SearchType.REVERSE:
q = q.filter(phone=self.valid_form["phone"]).all()
elif self.search_type == SearchType.QUICK:
if self.valid_form["quick"].strip():
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():
if key not in ("phone", "quick") and not (
value == "" or value is None
):
search_dict[key + "__icontains"] = value
q = q.filter(**search_dict).all()
else:
q = q.filter(pk__in=self.last_search).all()
if isinstance(q, list):
self.result_exists = len(q) > 0
else:
self.result_exists = q.exists()
self.last_search = []
for user in q:
self.last_search.append(user.id)
self.session["matmat_search_result"] = str(self.last_search)
return q
class SearchFormView(FormerSubscriberMixin, FormView):
"""Allows users to search inside the user list."""
form_class = SearchForm
def dispatch(self, request, *args, **kwargs):
self.session = request.session
self.init_query = User.objects
kwargs["form"] = self.get_form()
kwargs["search_type"] = self.search_type
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
view = SearchFormListView.as_view()
return view(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
form = self.get_form()
view = SearchFormListView.as_view()
if form.is_valid():
kwargs["valid_form"] = form.clean()
request.session["matmat_search_form"] = form.cleaned_data_json
return view(request, *args, **kwargs)
def get_initial(self):
init = self.session.get("matmat_search_form", {})
if not init:
init["department"] = ""
return init
class SearchNormalFormView(SearchFormView):
search_type = SearchType.NORMAL
class SearchReverseFormView(SearchFormView):
search_type = SearchType.REVERSE
class SearchQuickFormView(SearchFormView):
search_type = SearchType.QUICK
class SearchClearFormView(FormerSubscriberMixin, View):
"""Clear SearchFormView and redirect to it."""
def dispatch(self, request, *args, **kwargs):
super().dispatch(request, *args, **kwargs)
if "matmat_search_form" in request.session:
request.session.pop("matmat_search_form")
if "matmat_search_result" in request.session:
request.session.pop("matmat_search_result")
return HttpResponseRedirect(reverse("matmat:search"))

View File

@@ -1,9 +1,9 @@
from typing import Annotated, Literal from typing import 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 FilterLookup, FilterSchema, ModelSchema, Schema from ninja import 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,14 +114,13 @@ class UvSchema(ModelSchema):
class UvFilterSchema(FilterSchema): 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 semester: set[Literal["AUTUMN", "SPRING"]] | None = None
credit_type: Annotated[ credit_type: set[Literal["CS", "TM", "EC", "OM", "QC"]] | None = Field(
set[Literal["CS", "TM", "EC", "OM", "QC"]] | None, None, q="credit_type__in"
FilterLookup("credit_type__in"), )
] = None
language: str = "FR" 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: 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.5.0,<6.0.0", "django-ninja>=1.4.5,<2.0.0",
"django-ninja-extra>=0.30.6", "django-ninja-extra>=0.30.2,<1.0.0",
"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",
@@ -43,7 +43,7 @@ dependencies = [
"tomli>=2.3.0,<3.0.0", "tomli>=2.3.0,<3.0.0",
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0", "pydantic-extra-types>=2.10.6,<3.0.0",
"ical>=11.1.0,<12", "ical>=11.1.0,<13",
"redis[hiredis]<7,>=5.3.0", "redis[hiredis]<7,>=5.3.0",
"environs[django]>=14.5.0,<15.0.0", "environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0", "requests>=2.32.5,<3.0.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,19 +2,20 @@ 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 FilterLookup, FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt 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 from sas.models import Album, Picture, PictureModerationRequest
class AlbumFilterSchema(FilterSchema): class AlbumFilterSchema(FilterSchema):
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
before_date: Annotated[datetime | None, FilterLookup("event_date__lte")] = None before_date: datetime | None = Field(None, q="event_date__lte")
after_date: Annotated[datetime | None, FilterLookup("event_date__gte")] = None after_date: datetime | None = Field(None, q="event_date__gte")
parent_id: Annotated[int | None, FilterLookup("parent_id")] = None parent_id: int | None = Field(None, q="parent_id")
class SimpleAlbumSchema(ModelSchema): class SimpleAlbumSchema(ModelSchema):
@@ -59,12 +60,10 @@ class AlbumAutocompleteSchema(ModelSchema):
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
before_date: Annotated[datetime | None, FilterLookup("date__lte")] = None before_date: datetime | None = Field(None, q="date__lte")
after_date: Annotated[datetime | None, FilterLookup("date__gte")] = None after_date: datetime | None = Field(None, q="date__gte")
users_identified: Annotated[ users_identified: set[int] | None = Field(None, q="people__user_id__in")
set[int] | None, FilterLookup("people__user_id__in") album_id: int | None = Field(None, q="parent_id")
] = None
album_id: Annotated[int | None, FilterLookup("parent_id")] = None
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):

View File

@@ -177,6 +177,7 @@ TEMPLATES = [
"filters": { "filters": {
"markdown": "core.templatetags.renderer.markdown", "markdown": "core.templatetags.renderer.markdown",
"phonenumber": "core.templatetags.renderer.phonenumber", "phonenumber": "core.templatetags.renderer.phonenumber",
"truncate_time": "core.templatetags.renderer.truncate_time",
"format_timedelta": "core.templatetags.renderer.format_timedelta", "format_timedelta": "core.templatetags.renderer.format_timedelta",
"add_attr": "core.templatetags.renderer.add_attr", "add_attr": "core.templatetags.renderer.add_attr",
}, },
@@ -215,7 +216,7 @@ TEMPLATES = [
}, },
}, },
] ]
FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer"
HAYSTACK_CONNECTIONS = { HAYSTACK_CONNECTIONS = {
"default": { "default": {
@@ -439,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

959
uv.lock generated

File diff suppressed because it is too large Load Diff