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
15 changed files with 94 additions and 173 deletions

View File

@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
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.
It is insecure for use in hashing passwords, but is safe for hashing

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,9 +20,9 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import difflib
import re
from copy import copy
from datetime import date, datetime
from datetime import date, datetime, timedelta
from io import BytesIO
from captcha.fields import CaptchaField
@@ -390,11 +390,14 @@ class PageRevisionForm(forms.ModelForm):
- less than 20 minutes ago
- 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.
"""
TIME_THRESHOLD = timedelta(minutes=20)
DIFF_THRESHOLD = 0.2
class Meta:
model = PageRev
fields = ["title", "content"]
@@ -406,11 +409,21 @@ class PageRevisionForm(forms.ModelForm):
super().__init__(*args, instance=instance, **kwargs)
self.author = author
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
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.page = self.page
revision.id = None # if id is None, Django will create a new record

View File

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