Compare commits

..

10 Commits

Author SHA1 Message Date
imperosol
acdb9660f6 deps: bump django-ninja to 1.5.0 2025-11-23 00:48:32 +01:00
thomas girod
7373e3d9de Merge pull request #1254 from ae-utbm/refactor/page-merge
refactor detection of the need to merge `PageRev`
2025-11-19 13:52:52 +01:00
imperosol
3f4a41ba42 refactor detection of the need to merge PageRev 2025-11-19 13:51:38 +01:00
thomas girod
449abbb17e Merge pull request #1248 from ae-utbm/fix/api-barman-auth
fix: user search for anonymous sessions with logged barmen
2025-11-19 13:05:16 +01:00
thomas girod
9862e763ad Merge pull request #1249 from ae-utbm/membership-set-old
prevent csrf on `MembershipSetOldView`
2025-11-19 13:04:51 +01:00
imperosol
32e1f09d46 prevent csrf on MembershipSetOldView 2025-11-16 15:05:10 +01:00
imperosol
f359fab6b4 style: class for <a>-like form submit buttons 2025-11-16 15:04:30 +01:00
imperosol
0b53db7a95 fix: user search for anonymous sessions with logged barmen
Quand une session n'était pas connectée en tant qu'utilisateur, mais avait des utilisateurs connectés en tant que barman, la route de recherche des utilisateurs était 401
2025-11-16 13:31:48 +01:00
imperosol
d325b19383 typo in Sha512ApiKeyHasher docstring 2025-11-16 13:30:17 +01:00
imperosol
33cc9588b0 remove unused Mock 2025-11-16 13:12:58 +01:00
22 changed files with 743 additions and 576 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 sha256 algorithm. An API key hasher using the sha512 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,18 +1,16 @@
from typing import Annotated from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from club.models import Club, Membership from club.models import Club, Membership
from core.schemas import SimpleUserSchema from core.schemas import NonEmptyStr, SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema): class ClubSearchFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains") search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
is_active: bool | None = None is_active: bool | None = None
parent_id: int | None = None parent_id: int | None = None
parent_name: str | None = Field(None, q="parent__name__icontains")
exclude_ids: set[int] | None = None exclude_ids: set[int] | None = None
def filter_exclude_ids(self, value: set[int] | None): def filter_exclude_ids(self, value: set[int] | None):

View File

@@ -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 TestCase from django.test import Client, 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,6 +532,35 @@ 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, HttpResponseRedirect, StreamingHttpResponse from django.http import Http404, 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,6 +43,7 @@ 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 (
@@ -544,33 +545,17 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club" permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView): class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as beeing old.""" """Set a membership as being old."""
model = Membership model = Membership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
def get(self, request, *args, **kwargs): def post(self, *_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 HttpResponseRedirect( return redirect("core:user_clubs", user_id=self.object.user_id)
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):
@@ -582,7 +567,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 FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver from ninja_extra import service_resolver
from ninja_extra.context import RouteContext from ninja_extra.context import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema from club.schemas import ClubProfileSchema
from com.models import News, NewsDate from com.models import News, NewsDate
@@ -11,12 +11,12 @@ from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema): class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt") before: Annotated[datetime | None, FilterLookup("end_date__lt")] = None
after: datetime | None = Field(None, q="start_date__gt") after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None
club_id: int | None = Field(None, q="news__club_id") club_id: Annotated[int | None, FilterLookup("news__club_id")] = None
news_id: int | None = None news_id: int | None = None
is_published: bool | None = Field(None, q="news__is_published") is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None
title: str | None = Field(None, q="news__title__icontains") title: Annotated[str | None, FilterLookup("news__title__icontains")] = None
class NewsSchema(ModelSchema): class NewsSchema(ModelSchema):

View File

@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -18,16 +17,6 @@ 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
import annotated_types from annotated_types import Ge, Le, MinLen
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,6 +28,7 @@ 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")
@@ -72,7 +73,7 @@ class MailingListController(ControllerBase):
@api_controller("/user") @api_controller("/user")
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) @route.get("", response=list[UserProfileSchema])
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)
@@ -85,15 +86,18 @@ class UserController(ControllerBase):
"/search", "/search",
response=PaginatedResponseSchema[UserProfileSchema], response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users", url_name="search_users",
permissions=[CanAccessLookup], # logged in barmen aren't authenticated stricto sensu, so no auth here
auth=None,
) )
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter( qs = User.objects
User.objects.viewable_by(self.context.request.user).order_by( # the logged in barmen can see all users (even the hidden one),
F("last_login").desc(nulls_last=True) # because they have a temporary administrative function during
) # which they may have to deal with hidden users
) if not is_logged_in_counter(self.context.request):
qs = qs.viewable_by(self.context.request.user)
return filters.filter(qs.order_by(F("last_login").desc(nulls_last=True)))
@api_controller("/file") @api_controller("/file")
@@ -105,7 +109,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, annotated_types.MinLen(1)]): def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search) return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@@ -118,11 +122,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, annotated_types.MinLen(1)]): def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values() return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DepthValue = Annotated[int, Ge(0), Le(10)]
DEFAULT_DEPTH = 4 DEFAULT_DEPTH = 4

View File

@@ -23,12 +23,13 @@
# #
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, Self from typing import TYPE_CHECKING, Final, Self
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
@@ -1344,6 +1345,9 @@ 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)
@@ -1385,6 +1389,32 @@ 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

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

View File

@@ -21,6 +21,8 @@ $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

@@ -745,4 +745,32 @@ 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,9 +5,6 @@ $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;
@@ -251,12 +248,15 @@ $hovered-red-text-color: #ff4d4d;
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,19 +268,6 @@ $hovered-red-text-color: #ff4d4d;
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,7 +519,6 @@ 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

@@ -61,7 +61,9 @@
<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">{% trans %}Logout{% endtrans %}</button> <button type="submit" class="link-like link-red">
{% trans %}Logout{% endtrans %}
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,9 @@
<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>
@@ -28,7 +30,16 @@
<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><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td> <td>
<form
method="post"
action="{{ url('club:membership_set_old', membership_id=m.id) }}"
class="no-margin"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Mark as old{% endtrans %}" />
</form>
</td>
{% endif %} {% 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>
@@ -48,7 +59,9 @@
<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

@@ -1,4 +1,5 @@
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
@@ -23,6 +24,7 @@ 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, 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
@@ -60,7 +62,9 @@ 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(reverse("api:search_users") + "?search=First") response = self.client.get(
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
@@ -69,7 +73,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]]
@@ -82,14 +86,19 @@ 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 nick name.""" """Test that the search can be done on the nickname."""
# hidden users should not be in the final result,
# even when the nickname matches
self.users[10].is_viewable = False
self.users[10].save()
self.client.force_login(subscriber_user.make()) 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(reverse("api:search_users") + "?search=Nick1") response = self.client.get(
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,
] ]
@@ -101,10 +110,25 @@ 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") + "?search=bél") response = self.client.get(reverse("api:search_users", query={"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`)."""

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 datetime import date, datetime, timedelta from copy import copy
from datetime import date, datetime
from io import BytesIO from io import BytesIO
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
@@ -390,14 +390,11 @@ class PageRevisionForm(forms.ModelForm):
- less than 20 minutes ago - less than 20 minutes ago
- by the same author - by the same author
- with a diff ratio higher than 20% - with a similarity ratio higher than 80%
then the latter will be edited and the new revision won't be created. 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"]
@@ -409,21 +406,11 @@ 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_content = instance.content if instance else "" self.initial_obj: PageRev = copy(self.instance)
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 ( if not self.initial_obj.should_merge(self.instance):
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

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

View File

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

View File

@@ -20,8 +20,8 @@ license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12" requires-python = "<4.0,>=3.12"
dependencies = [ dependencies = [
"django>=5.2.8,<6.0.0", "django>=5.2.8,<6.0.0",
"django-ninja>=1.4.5,<2.0.0", "django-ninja>=1.5.0,<6.0.0",
"django-ninja-extra>=0.30.2,<1.0.0", "django-ninja-extra>=0.30.6",
"Pillow>=12.0.0,<13.0.0", "Pillow>=12.0.0,<13.0.0",
"mistune>=3.1.4,<4.0.0", "mistune>=3.1.4,<4.0.0",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
@@ -68,7 +68,7 @@ dev = [
"pre-commit>=4.3.0,<5.0.0", "pre-commit>=4.3.0,<5.0.0",
"ruff>=0.14.4,<1.0.0", "ruff>=0.14.4,<1.0.0",
"djhtml>=3.0.10,<4.0.0", "djhtml>=3.0.10,<4.0.0",
"faker>=37.12.0,<39.0.0", "faker>=37.12.0,<38.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",
] ]
tests = [ tests = [

View File

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

955
uv.lock generated

File diff suppressed because it is too large Load Diff