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
17 changed files with 101 additions and 237 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 %}
<td></td>
</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]]
@@ -86,19 +82,14 @@ class TestSearchUsersAPI(TestSearchUsers):
assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self):
"""Test that the search can be done on the 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()
"""Test that the search can be done on the nick name."""
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

@@ -235,19 +235,6 @@ class ScheduledProductActionForm(forms.ModelForm):
)
return super().clean()
def set_product(self, product: Product):
"""Set the product to which this form's instance is linked.
When this form is linked to a ProductForm in the case of a product's creation,
the product doesn't exist yet, so saving this form as is will result
in having `{"product_id": null}` in the action kwargs.
For the creation to be useful, it may be needed to inject the newly created
product into this form, before saving the latter.
"""
self.product = product
kwargs = json.loads(self.instance.kwargs) | {"product_id": self.product.id}
self.instance.kwargs = json.dumps(kwargs)
class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs):
@@ -334,19 +321,11 @@ class ProductForm(forms.ModelForm):
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"])
for form in self.action_formset:
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
form.set_product(product)
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
self.action_formset.save()
return product
return ret
class ReturnableProductForm(forms.ModelForm):
@@ -390,6 +369,7 @@ class EticketForm(forms.ModelForm):
class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField(
label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),

View File

@@ -11,12 +11,8 @@ from model_bakery import baker
from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import (
ProductForm,
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
from counter.models import ScheduledProductAction
@pytest.mark.django_db
@@ -38,39 +34,6 @@ def test_edit_product(client: Client):
assert res.status_code == 200
@pytest.mark.django_db
def test_create_actions_alongside_product():
"""The form should work when the product and the actions are created alongside."""
# non-persisted instance
product: Product = product_recipe.prepare(_save_related=True)
trigger_at = now() + timedelta(minutes=10)
form = ProductForm(
data={
"name": "foo",
"description": "bar",
"product_type": product.product_type_id,
"club": product.club_id,
"code": "FOO",
"purchase_price": 1.0,
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product",
"form-0-trigger_at": trigger_at,
},
)
assert form.is_valid()
product = form.save()
action = ScheduledProductAction.objects.last()
assert action.clocked.clocked_time == trigger_at
assert action.enabled is True
assert action.one_off is True
assert action.task == "counter.tasks.archive_product"
assert action.kwargs == json.dumps({"product_id": product.id})
@pytest.mark.django_db
class TestProductActionForm:
def test_single_form_archive(self):

View File

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