Compare commits

..
Author SHA1 Message Date
imperosol cea997350e sqsdqd 2026-05-23 16:28:40 +02:00
imperosol e362d29808 WIP 2026-05-23 16:28:37 +02:00
imperosol 9a0ae505e0 get_list_exact_or_404 util function 2026-05-23 16:24:19 +02:00
imperosol 610184900f fix some tests 2026-05-23 16:24:16 +02:00
imperosol dc8a678e39 Migrate albums and pictures to their own tables 2026-05-23 16:24:03 +02:00
234 changed files with 6310 additions and 7162 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.0
rev: v0.15.13
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@2.5.1"]
additional_dependencies: ["@biomejs/biome@2.4.6"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.11
hooks:
+1 -1
View File
@@ -1 +1 @@
3.14
3.12
+2 -6
View File
@@ -1,15 +1,11 @@
from typing import TYPE_CHECKING
from django.contrib import admin, messages
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
@admin.register(ApiClient)
class ApiClientAdmin(admin.ModelAdmin):
+1 -5
View File
@@ -1,13 +1,9 @@
from typing import TYPE_CHECKING
from django.http import HttpRequest
from ninja.security import APIKeyHeader
from api.hashers import get_hasher
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.http import HttpRequest
class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
+11 -8
View File
@@ -39,16 +39,14 @@ Example:
import operator
from functools import reduce
from typing import TYPE_CHECKING, Any, Callable
from typing import Any, Callable
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
from counter.models import Counter
class IsInGroup(BasePermission):
@@ -188,7 +186,12 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return is_logged_in_counter(request)
if "/counter/" not in request.META.get("HTTP_REFERER", ""):
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
+1 -1
View File
@@ -17,7 +17,7 @@
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"recommended": true,
"style": {
"useNamingConvention": "error"
},
+4 -8
View File
@@ -12,16 +12,12 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import TYPE_CHECKING
from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
if TYPE_CHECKING:
from django.forms.models import ModelForm
from django.http import HttpRequest
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
@@ -50,8 +46,8 @@ class ClubAdmin(admin.ModelAdmin):
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name", "club__name")
autocomplete_fields = ("club", "linked_groups")
search_fields = ("name",)
autocomplete_fields = ("club",)
list_select_related = ("club",)
list_filter = (
"is_board",
-41
View File
@@ -11,7 +11,6 @@ from club.models import Club, Membership
from club.schemas import (
ClubSchema,
ClubSearchFilterSchema,
MembershipFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
@@ -63,43 +62,3 @@ class UserClubController(ControllerBase):
.filter(user=user)
.select_related("club", "user", "role")
)
@api_controller("/clubs/members/")
class ClubMembershipController(ControllerBase):
@route.get(
"/new",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("club.view_club")],
url_name="get_new_clubs_members_since_date",
)
def fetch_new_club_members(self, filters: Query[MembershipFilterSchema]):
"""give all the members of all clubs that have joined since a given date"""
memberships = (
Membership.objects.ongoing()
.filter(start_date__gte=filters.since_date, end_date__isnull=True)
.select_related("user", "role", "club")
)
if filters.clubs_id:
memberships = memberships.filter(club_id__in=filters.clubs_id)
return memberships.order_by("start_date")
@route.get(
"/former",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("club.view_club")],
url_name="get_former_clubs_members_since_date",
)
def fetch_former_club_members(self, filters: Query[MembershipFilterSchema]):
"""give all the former members of all clubs that have left since a given date"""
memberships = Membership.objects.filter(
start_date__lt=filters.since_date,
end_date__gte=filters.since_date,
).select_related("user", "role", "club")
if filters.clubs_id:
memberships = memberships.filter(club_id__in=filters.clubs_id)
return memberships.order_by("start_date")
-65
View File
@@ -21,13 +21,10 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import itertools
from operator import attrgetter
from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models.functions import Lower
from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
@@ -49,37 +46,6 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubRoleChoiceIterator(ModelChoiceIterator):
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
def __iter__(self):
if self.field.empty_label is not None:
yield "", self.field.empty_label
queryset = self.queryset.select_related("club").order_by("club", "order")
groups = [
(club, [self.choice(role) for role in roles])
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
]
if len(groups) == 1:
# there is only one club involved, no need to have optgroups
yield from groups[0][1]
else:
# there are multiple clubs, optgroups are necessary to differentiate
# roles having the same name
yield from groups
class ClubRoleChoiceField(ModelChoiceField):
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
If only one club is involved, behave like the base `ModelChoiceField`.
If dealing with the roles of multiple clubs, group the roles
into a different `optgroup` for each club.
"""
iterator = ClubRoleChoiceIterator
class ClubLinkForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
@@ -426,30 +392,6 @@ class ClubRoleForm(forms.ModelForm):
self.instance.order = cleaned_data["ORDER"] - 1
return cleaned_data
def save(self, commit=True): # noqa: FBT002
instance: ClubRole = super().save(commit=commit)
if commit and "is_board" in self.changed_data:
# if the role was moved from board to simple member,
# remove all users with that role from the club board group.
# If the role became a board role, add users with
# that role to the club board group.
group_id = instance.club.board_group_id
if self.cleaned_data["is_board"]:
User.groups.through.objects.bulk_create(
[
User.groups.through(user_id=u, group_id=group_id)
for u in Membership.objects.ongoing()
.filter(role=instance)
.values_list("user_id", flat=True)
],
ignore_conflicts=True,
)
else:
User.groups.through.objects.filter(
user__memberships__role=instance, group_id=group_id
).delete()
return instance
class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role.
@@ -479,13 +421,6 @@ class ClubRoleCreateForm(forms.ModelForm):
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput()
def __init__(self, *args, queryset=None, **kwargs):
if queryset is None:
queryset = self.model._default_manager
super().__init__(
*args, queryset=queryset.prefetch_related("linked_groups"), **kwargs
)
ClubRoleFormSet = forms.inlineformset_factory(
Club,
@@ -1,16 +1,12 @@
# Generated by Django 4.2.16 on 2024-11-20 17:08
from typing import TYPE_CHECKING
import django.db.models.deletion
import django.db.models.functions.datetime
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
# Before the club role rework, the maximum free role
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
@@ -1,14 +1,10 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = {
+2 -1
View File
@@ -25,7 +25,8 @@ class Migration(migrations.Migration):
"url_base",
models.URLField(
help_text=(
"The base url that links with this type must respect"
"The base url that links with this type "
"must respect (e.g. `https://www.instagram.com`)"
),
unique=True,
verbose_name="url base",
@@ -1,26 +0,0 @@
# Generated by Django 5.2.17 on 2026-09-01 14:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0017_linktype_clublink"),
("core", "0050_alter_sithfile_moderator"),
]
operations = [
migrations.AddField(
model_name="clubrole",
name="linked_groups",
field=models.ManyToManyField(
help_text=(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="club_roles",
to="core.group",
verbose_name="Linked groups",
),
),
]
+16 -34
View File
@@ -23,8 +23,6 @@
#
from __future__ import annotations
import operator
from functools import reduce
from typing import Iterable, Self
from django.conf import settings
@@ -284,15 +282,6 @@ class ClubRole(OrderedModel):
"If the role is inactive, people joining the club won't be able to get it."
),
)
linked_groups = models.ManyToManyField(
Group,
verbose_name=_("Linked groups"),
help_text=_(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="linked_roles",
)
order_with_respect_to = "club"
@@ -545,7 +534,7 @@ class Membership(models.Model):
def _remove_club_groups(
memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club and club role groups.
"""Remove users of those memberships from the club groups.
For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll.
@@ -564,19 +553,15 @@ class Membership(models.Model):
clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
role_groups = [
Q(user_id=m.user_id, group__linked_roles=m.role_id) for m in memberships
]
return User.groups.through.objects.filter(
(Q(group__in=groups) & Q(user__in=users))
| reduce(operator.or_, role_groups)
Q(group__in=groups) & Q(user__in=users)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> list[User.groups.through]:
"""Add users of those memberships to the club and club role groups.
"""Add users of those memberships to the club groups.
For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group
@@ -597,40 +582,34 @@ class Membership(models.Model):
memberships = [m for m in memberships if m.end_date is None]
if not memberships:
return []
nb_prefetched = sum(
1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role")
)
if nb_prefetched > 1:
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
# if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with
# a select_related than perform a distinct query
# to fetch each club.
ids = {m.id for m in memberships}
memberships = list(
Membership.objects.filter(id__in=ids)
.select_related("club", "role")
.prefetch_related("role__linked_groups")
Membership.objects.filter(id__in=ids).select_related("club")
)
groups = []
club_groups = []
for membership in memberships:
groups.append(
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role.is_board:
groups.append(
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.board_group_id,
)
)
groups.extend(
User.groups.through(user_id=membership.user_id, group_id=g.id)
for g in membership.role.linked_groups.all()
)
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
return User.groups.through.objects.bulk_create(
club_groups, ignore_conflicts=True
)
class Mailing(models.Model):
@@ -814,7 +793,10 @@ class LinkType(models.Model):
url_base = models.URLField(
"url base",
unique=True,
help_text=_("The base url that links with this type must respect"),
help_text=_(
"The base url that links with this type must respect (e.g. `%(url)s`)"
)
% {"url": "https://www.instagram.com"},
)
icon = models.CharField(
_("icon"),
-7
View File
@@ -1,4 +1,3 @@
from datetime import date
from typing import Annotated
from django.db.models import Q
@@ -80,9 +79,3 @@ class UserMembershipSchema(ModelSchema):
club: SimpleClubSchema
role: ClubRoleSchema
user: SimpleUserSchema
class MembershipFilterSchema(FilterSchema):
since_date: Annotated[date, FilterLookup("date__lte")]
clubs_id: set[int] | None = None
@@ -1,6 +1,7 @@
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
@@ -9,7 +10,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string) {
protected async search(query: string): Promise<TomOption[]> {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
-7
View File
@@ -45,10 +45,3 @@
}
}
}
@media screen and (max-width: 575px){
#club-list{
padding-left: 0;
padding-right: 0;
}
}
+6 -11
View File
@@ -15,6 +15,9 @@
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
@@ -23,12 +26,6 @@
{%- endblock %}
{% endif %}
{% block additional_css %}
{% if not is_fragment %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endif %}
{% endblock %}
{% from "core/macros.jinja" import paginate_htmx %}
{% block content %}
@@ -36,17 +33,15 @@
<h3>{% trans %}Filters{% endtrans %}</h3>
<form
id="club-list-filters"
method="GET"
hx-action="{{ url("club:club_list") }}"
hx-get="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="innerHTML"
hx-swap="outerHtml"
hx-push-url="true"
hx-disable="find input, find button"
>
<div class="row gap-4x">
{{ form }}
</div>
<button class="btn btn-blue margin-bottom">
<button type="submit" class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button>
</form>
-14
View File
@@ -49,20 +49,6 @@
{{ subform.is_active.help_text }}
</span>
</div>
{% set groups = subform.instance.linked_groups.all()|list %}
{% if groups %}
<div>
<p>
<strong>{% trans %}Linked groups : {% endtrans %}</strong>
{{ groups|map(attribute="name")|join(", ") }}
</p>
<p class="helptext">
{% trans trimmed %}
Users receiving this role will also be assigned to those groups
{% endtrans %}
</p>
</div>
{% endif %}
</div>
</details>
</div>
@@ -7,7 +7,7 @@
<form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disable="find input[type='submit']"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
hx-target="#member-fragment-container"
id="add_club_members_form"
+1 -4
View File
@@ -1,9 +1,9 @@
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
@@ -14,9 +14,6 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_club_queryset_having_board_member():
@@ -1,203 +0,0 @@
from datetime import timedelta
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import User
class TestMembershipAPI(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = baker.make(User)
perm = Permission.objects.get(codename="view_club")
cls.user.user_permissions.add(perm)
cls.clubs = baker.make(Club, _quantity=3, is_active=True)
cls.roles = baker.make(ClubRole, _quantity=3, is_active=True)
cls.expectedNumQueries = 5
# Clean existing data to avoid side effects
Membership.objects.all().delete()
cls.memberships = [
# on going
Membership.objects.create(
club=cls.clubs[0],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=1),
),
# on going
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[1],
start_date=localdate() - timedelta(days=1),
),
# former
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[2],
start_date=localdate() - timedelta(weeks=2),
end_date=localdate() - timedelta(days=6),
),
# on going
Membership.objects.create(
club=cls.clubs[2],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=3),
),
# former
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[2],
start_date=localdate() - timedelta(days=4),
end_date=localdate() - timedelta(days=3),
),
# on going
Membership.objects.create(
club=cls.clubs[2],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(days=1),
),
# former
Membership.objects.create(
club=cls.clubs[0],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=6),
end_date=localdate() - timedelta(days=3),
),
# former
Membership.objects.create(
club=cls.clubs[2],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=8),
end_date=localdate() - timedelta(days=6),
),
# former
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=8),
end_date=localdate() - timedelta(weeks=7, days=5),
),
]
class TestNewMembershipAPI(TestMembershipAPI):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.url = reverse("api:get_new_clubs_members_since_date")
def test_new_membership_one_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date, "clubs_id": self.clubs[0].id}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[0].id]
assert membership_ids == expected_ids
def test_new_membership_multiple_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {
"since_date": since_date,
"clubs_id": [self.clubs[0].id, self.clubs[1].id],
}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[0].id, self.memberships[1].id]
assert membership_ids == expected_ids
def test_new_membership_all_clubs(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [
self.memberships[0].id,
self.memberships[1].id,
self.memberships[5].id,
]
assert membership_ids == expected_ids
class TestFormerMembershipAPI(TestMembershipAPI):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.url = reverse("api:get_former_clubs_members_since_date")
def test_former_membership_one_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date, "clubs_id": self.clubs[1].id}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[2].id]
assert membership_ids == expected_ids
def test_new_membership_multiple_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {
"since_date": since_date,
"clubs_id": [self.clubs[1].id, self.clubs[0].id],
}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[6].id, self.memberships[2].id]
assert membership_ids == expected_ids
def test_new_membership_all_clubs(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [
self.memberships[7].id,
self.memberships[6].id,
self.memberships[2].id,
]
assert membership_ids == expected_ids
+2 -32
View File
@@ -1,10 +1,9 @@
from typing import TYPE_CHECKING
from collections.abc import Callable
import pytest
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
@@ -14,9 +13,6 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
def make_club():
# unittest-style tests cannot use fixture, so we create a function
@@ -243,7 +239,7 @@ class TestClubRoleUpdate(TestCase):
def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency,
then it loses access to the update page."""
then it's redirected to another page and loses access to the update page."""
self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload)
@@ -255,29 +251,3 @@ class TestClubRoleUpdate(TestCase):
res = self.client.get(self.url)
assert res.status_code == 403
def test_role_stops_being_board(self):
"""Test that if a role stops being a board role,
its users lose the club board group."""
self.payload["roles-0-is_board"] = False
self.payload["roles-0-is_presidency"] = False
self.payload["roles-1-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert not self.user.groups.contains(self.club.board_group)
def test_role_becomes_board(self):
"""Test that if a role becomes a board role,
its active users get the club board group"""
members = [
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
]
self.payload["roles-2-is_board"] = True
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
# the second membership is finished, so its user shouldn't get the role
assert members[0].user.groups.contains(self.club.board_group)
assert not members[1].user.groups.contains(self.club.board_group)
+1 -5
View File
@@ -1,6 +1,5 @@
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
@@ -8,9 +7,6 @@ from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_club_board_member_cannot_edit_club_properties(client: Client):
+2 -28
View File
@@ -1,6 +1,6 @@
import itertools
from collections.abc import Callable
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
@@ -17,10 +17,7 @@ from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
if TYPE_CHECKING:
from collections.abc import Callable
from core.models import AnonymousUser, User
class TestMembershipQuerySet(TestClub):
@@ -503,29 +500,6 @@ class TestMembership(TestClub):
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
def test_add_to_club_role_group(self):
groups = baker.make(Group, _quantity=4)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:3])
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert set(self.subscriber.groups.all()) == {
*groups[:3],
self.club.board_group,
self.club.members_group,
}
def test_remove_from_club_role_group(self):
groups = baker.make(Group, _quantity=3)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:])
self.subscriber.memberships.update(end_date=localdate())
assert set(self.subscriber.groups.all()) == {groups[0]}
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
+2 -6
View File
@@ -1,19 +1,15 @@
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client):
+1 -5
View File
@@ -1,6 +1,5 @@
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
@@ -8,9 +7,6 @@ from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
+1 -4
View File
@@ -1,8 +1,8 @@
import csv
import itertools
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
@@ -12,9 +12,6 @@ from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer, Product, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client):
+1 -4
View File
@@ -1,5 +1,4 @@
from datetime import date
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta
from django import forms
@@ -10,13 +9,11 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from core.models import User
class PosterForm(forms.ModelForm):
class Meta:
+3 -8
View File
@@ -1,10 +1,11 @@
from typing import TYPE_CHECKING
from pathlib import Path
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
@@ -13,13 +14,7 @@ from ical.event import Event
from ical.types import Frequency, Recur
from com.models import News, NewsDate
if TYPE_CHECKING:
from pathlib import Path
from django.http import HttpRequest
from core.models import User
from core.models import User
def as_absolute_url(url: str, request: HttpRequest | None = None) -> str:
+1 -1
View File
@@ -1,13 +1,13 @@
from datetime import datetime
from typing import Annotated
from aemark import markdown
from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.context import RouteContext
from club.schemas import ClubProfileSchema
from com.models import News, NewsDate
from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema):
@@ -1,9 +1,4 @@
import {
Calendar,
type EventClickArg,
type EventContentArg,
type EventInput,
} from "@fullcalendar/core";
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
@@ -11,8 +6,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { type GenericEndpoint, makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -24,11 +19,11 @@ import {
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar!: Calendar;
private locale? = "en";
private canModerate? = false;
private canDelete? = false;
private helpUrl? = "";
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
private helpUrl = "";
// Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules
@@ -40,12 +35,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.locale = newValue;
}
if (name === "can_moderate") {
this.canModerate =
typeof newValue === "string" && newValue.toLowerCase() === "true";
this.canModerate = newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete =
typeof newValue === "string" && newValue.toLowerCase() === "true";
this.canDelete = newValue.toLowerCase() === "true";
}
if (name === "ics-help-url") {
@@ -101,7 +94,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop() as string,
.pop(),
10,
);
}
@@ -166,7 +159,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents();
}
async getEventSources(): Promise<EventInput[]> {
async getEventSources() {
const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url
// We rely on the fact that the event url is always the same
@@ -180,14 +173,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
};
return [
{
url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
url: `${await makeUrl(calendarCalendarInternal)}`,
format: "ics",
className: "internal",
cache: false,
eventDataTransform: tagRecurringEvents,
},
{
url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
url: `${await makeUrl(calendarCalendarUnpublished)}`,
format: "ics",
color: "red",
className: "unpublished",
@@ -220,7 +213,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title}
</h4>
<span class="event-details-row-content">
${this.formatDate(event.start as Date)} - ${this.formatDate(event.end as Date)}
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
</span>
</div>
`;
@@ -258,7 +251,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) {
if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")}
@@ -345,9 +338,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied");
}
button.setAttribute("tooltip", gettext("Link copied"));
await navigator.clipboard.writeText(
navigator.clipboard.writeText(
new URL(
await makeUrl(calendarCalendarInternal as GenericEndpoint),
await makeUrl(calendarCalendarInternal),
window.location.origin,
).toString(),
);
@@ -1,9 +1,4 @@
import {
newsDeleteNews,
newsFetchNewsDates,
newsPublishNews,
type PaginatedResponseSchemaNewsDateSchema,
} from "#openapi";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript
@@ -69,8 +64,7 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
return response.data.count;
},
weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
type ParsedNewsDateSchema = Omit<NewsDateSchema, "start_date" | "end_date"> & {
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
};
}
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,11 +32,6 @@ document.addEventListener("alpine:init", () => {
page_size: this.pageSize,
},
});
if (response.response === undefined || response.data === undefined) {
// response may be undefined, because error may be
// from building the request object itself or from a network error
return;
}
if (response.response.status === 404) {
this.hasNext = false;
} else if (response.data.next === null) {
@@ -49,7 +44,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false;
},
groupedDates(): Record<string, ParsedNewsDateSchema[]> {
groupedDates(): Record<string, NewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
+2 -5
View File
@@ -1,11 +1,10 @@
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from django.conf import settings
from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
@@ -14,11 +13,9 @@ from pytest_django.asserts import assertNumQueries
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User
if TYPE_CHECKING:
from django.http import HttpResponse
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
+1 -1
View File
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
form_class = NewsForm
template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id"
permission_required = "com.change_news"
permission_required = "com.edit_news"
def form_valid(self, form):
response = super().form_valid(form) # Does the saving part
+2 -2
View File
@@ -99,9 +99,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date", "is_in_sas")
list_display = ("name", "owner", "size", "date")
autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name", "parent__name")
search_fields = ("name",)
@admin.register(OperationLog)
+14 -4
View File
@@ -1,8 +1,9 @@
from typing import Annotated, Literal
from typing import Annotated, Any, Literal
from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
@@ -17,6 +18,7 @@ from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
UploadedImage,
@@ -24,18 +26,26 @@ from core.schemas import (
UserFilterSchema,
UserProfileSchema,
UserSchema,
ValidationErrorSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown")
class MarkdownController(ControllerBase):
@route.post("", url_name="markdown")
def render_markdown(self, body: MarkdownSchema):
"""Convert the markdown text into html."""
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/upload")
class UploadController(ControllerBase):
@route.post(
"/image",
response={
200: UploadedFileSchema,
422: ValidationErrorSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
403: dict[Literal["detail"], str],
},
permissions=[HasPerm("core.add_quickuploadimage")],
@@ -100,7 +110,7 @@ class SithFileController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
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(name__icontains=search)
@api_controller("/group")
+36 -35
View File
@@ -2,9 +2,12 @@
<h1>Markdown-AE Documentation</h1>
<p>Le Markdown le plus standard se trouve documenté ici:
<a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br />
Si cette page nest pas exhaustive vis à vis de la syntaxe du site AE,
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
elle a au moins le mérite de bien documenter le Markdown original.</p>
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/kivikakk/comrak">comrak</a>.</p>
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/lepture/mistune">mistune</a>.<br />
Les plus aventureux pourront aller lire ses <a href="https://github.com/lepture/mistune/blob/master/tests/fixtures">tests</a>
afin d'en connaître la syntaxe le plus finement possible.<br />
En pratique, cette page devrait déjà résumer une bonne partie.</p>
<h2>Basique</h2>
<ul>
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
@@ -12,8 +15,8 @@ elle a au moins le mérite de bien documenter le Markdown original.</p>
<li><u>Souligner</u> le texte : <code>__texte__</code></li>
<li><del>Barrer du texte</del> : <code>~~texte~~</code></li>
<li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li>
<li>Mettre du texte<sup>en exposant</sup> : <code>&lt;sup&gt;texte&lt;/sup&gt;</code></li>
<li>Mettre du texte<sub>en indice</sub> : <code>&lt;sub&gt;texte&lt;/sub&gt;</code></li>
<li>Mettre du texte^en exposant^ : <code>&lt;sup&gt;texte&lt;/sup&gt;</code></li>
<li>Mettre du texte~en indice~ : <code>&lt;sub&gt;texte&lt;/sub&gt;</code></li>
</ul>
<h2>Liens</h2>
<ul>
@@ -25,10 +28,10 @@ elle a au moins le mérite de bien documenter le Markdown original.</p>
</ul>
<p><a href="http://www.site.com">nom du lien</a></p>
<ul>
<li>Les liens peuvent être internes au site de lAE, on peut dès lors éviter dentrer
ladresse complète dune page : <code>[nom du lien](page://nomDeLaPage)</code></li>
<li>Les liens peuvent être internes au site de l'AE, on peut dès lors éviter d'entrer
l'adresse complète d'une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
</ul>
<p><a href="/page/nomDeLaPage">nom du lien</a></p>
<p><a href="/page/nomDeLaPage/">nom du lien</a></p>
<ul>
<li>On peut également utiliser une image pour les liens :
<code>[nom du lien]![images/imageDuSiteAE.png](/chemin/vers/image.png titre optionnel)(options)</code></li>
@@ -91,25 +94,25 @@ etc...
<table>
<thead>
<tr>
<th>Titre</th>
<th>Titre2</th>
<th>Titre3</th>
<th>Titre</th>
<th>Titre2</th>
<th>Titre3</th>
</tr>
</thead>
<tbody>
<tr>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
</tr>
<tr>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
</tr>
</tbody>
</table>
<p>Lalignement dans les cellules est géré comme suit, avec les : sur la ligne en dessous du titre:</p>
<p>L'alignement dans les cellules est géré comme suit, avec les ':' sur la ligne en dessous du titre:</p>
<pre><code>| Titre | Titre2 | Titre3 |
|:-------|:------:|-------:|
| gauche | centre | droite |
@@ -117,16 +120,16 @@ etc...
<table>
<thead>
<tr>
<th align="left">Titre</th>
<th align="center">Titre2</th>
<th align="right">Titre3</th>
<th style="text-align:left">Titre</th>
<th style="text-align:center">Titre2</th>
<th style="text-align:right">Titre3</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">gauche</td>
<td align="center">centre</td>
<td align="right">droite</td>
<td style="text-align:left">gauche</td>
<td style="text-align:center">centre</td>
<td style="text-align:right">droite</td>
</tr>
</tbody>
</table>
@@ -138,11 +141,11 @@ etc...
![image de 350 pixels de large](/static/core/img/logo.png?350 &quot;Image de 350 pixels&quot;)
![image de 350x100 pixels](/static/core/img/logo.png?350x100 &quot;Image de 350x100 pixels&quot;)
</code></pre>
<p><img src="/static/core/img/logo.png" style="width:50%" alt="image à 50%" title="Image à 50%" /><br />
<p><img src="/static/core/img/logo.png" alt="image à 50%" title="Image à 50%" style="width:50%;" /><br />
Image à 50% de la largeur de la page.</p>
<p><img src="/static/core/img/logo.png" style="width:350px" alt="image de 350 pixels de large" title="Image de 350 pixels" /><br />
<p><img src="/static/core/img/logo.png" alt="image de 350 pixels de large" title="Image de 350 pixels" style="width:350px;" /><br />
Image de 350 pixels de large.</p>
<p><img src="/static/core/img/logo.png" style="width:350px;height:100px" alt="image de 350x100 pixels" title="Image de 350x100 pixels" /><br />
<p><img src="/static/core/img/logo.png" alt="image de 350x100 pixels" title="Image de 350x100 pixels" style="width:350px;height:100px;" /><br />
Image de 350x100 pixels.</p>
<p>(devrait pouvoir détecter si vidéo ou non)</p>
<h2>Blocs de citations</h2>
@@ -156,9 +159,9 @@ Image de 350x100 pixels.</p>
un bloc de
citation</p>
</blockquote>
<p>Il est possible dintégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
<p>Il est possible d'intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
<h2>Note de bas de page</h2>
<p>On les crée comme ça<sup class="footnote-ref"><a href="#fn-key" id="fnref-key" data-footnote-ref>1</a></sup>:</p>
<p>On les crée comme ça<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>:</p>
<pre><code>Je fais une note[^clef].
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
@@ -172,15 +175,13 @@ citation</p>
</code></pre>
<h2>Échapper des caractères</h2>
<ul>
<li>Il est possible dignorer un caractère spécial en léchappant à laide dun \</li>
<li>Léchappement de blocs de codes complet se fera à laide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li>
<li>Il est possible d'ignorer un caractère spécial en l'échappant à l'aide d'un \</li>
<li>L'échappement de blocs de codes complet se fera à l'aide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li>
</ul>
<h2>Autres (hérité de lancien wiki)</h2>
<h2>Autres (hérité de l'ancien wiki)</h2>
<p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p>
<section class="footnotes" data-footnotes>
<section class="footnotes">
<ol>
<li id="fn-key">
<p>ceci est le contenu de ma clef <a href="#fnref-key" class="footnote-backref" data-footnote-backref data-footnote-backref-idx="1" aria-label="Back to reference 1"></a></p>
</li>
<li id="fn-1"><p>ceci est le contenu de ma clef<a href="#fnref-1" class="footnote">&#8617;</a></p></li>
</ol>
</section>
+4 -1
View File
@@ -7,7 +7,10 @@ https://www.markdownguide.org/basic-syntax.
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
elle a au moins le mérite de bien documenter le Markdown original.
Le réel parseur du site AE est une version tunée de [comrak](https://github.com/kivikakk/comrak).
Le réel parseur du site AE est une version tunée de [mistune](https://github.com/lepture/mistune).
Les plus aventureux pourront aller lire ses [tests](https://github.com/lepture/mistune/blob/master/tests/fixtures)
afin d'en connaître la syntaxe le plus finement possible.
En pratique, cette page devrait déjà résumer une bonne partie.
## Basique
+2 -1
View File
@@ -22,10 +22,11 @@
#
from aemark import markdown
from django.conf import settings
from django.core.management.base import BaseCommand
from core.markdown import markdown
class Command(BaseCommand):
help = "Output the fully rendered SYNTAX.md file"
+63 -158
View File
@@ -20,7 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from datetime import date, datetime, timedelta
from datetime import date, timedelta
from io import StringIO
from pathlib import Path
from typing import ClassVar, NamedTuple
@@ -33,8 +33,7 @@ from django.core.management.base import BaseCommand
from django.db import connection
from django.db.models import Q
from django.utils import timezone
from django.utils.lorem_ipsum import paragraphs
from django.utils.timezone import localdate, now
from django.utils.timezone import localdate
from PIL import Image
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
@@ -44,14 +43,13 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from counter.models import (
Counter,
CounterSellers,
Price,
Product,
ProductType,
ReturnableProduct,
StudentCard,
)
from election.models import Candidature, Election, ElectionList, Role, Vote
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture
@@ -126,8 +124,9 @@ class Command(BaseCommand):
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
clubs = self._create_clubs()
@@ -366,15 +365,62 @@ class Command(BaseCommand):
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
# Add barman to counter
CounterSellers.objects.bulk_create(
Counter.sellers.through.objects.bulk_create(
[
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
Counter.sellers.through(counter_id=1, user=skia), # MDE
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
]
)
# Create an election
self._create_elections(groups, clubs, skia, sli, krophil)
el = Election.objects.create(
title="Élection 2017",
description="La roue tourne",
start_candidature="1942-06-12 10:28:45+01",
end_candidature="2042-06-12 10:28:45+01",
start_date="1942-06-12 10:28:45+01",
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
listeT = ElectionList.objects.create(title="Troll", election=el)
pres = Role.objects.create(
election=el, title="Président AE", description="Roi de l'AE"
)
resp = Role.objects.create(
election=el, title="Co Respo Info", max_choice=2, description="Ghetto++"
)
Candidature.objects.bulk_create(
[
Candidature(
role=resp,
user=skia,
election_list=liste,
program="Refesons le site AE",
),
Candidature(
role=resp,
user=sli,
election_list=liste,
program="Vasy je deviens mon propre adjoint",
),
Candidature(
role=resp,
user=krophil,
election_list=listeT,
program="Le Pôle Troll !",
),
Candidature(
role=pres,
user=sli,
election_list=listeT,
program="En fait j'aime pas l'info, je voulais faire GMC",
),
]
)
# Forum
room = Forum.objects.create(
@@ -530,32 +576,20 @@ class Command(BaseCommand):
# SAS
for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir():
album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
album = Album.objects.create(name=f.name, is_moderated=True)
for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture(
parent=album,
name=p.name,
file=file,
original=file,
owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True,
mime_type="image/webp",
size=file.size,
)
pict.file.name = p.name
pict.full_clean()
pict.generate_thumbnails(save=True)
pict.original.name = pict.name
pict.generate_thumbnails()
pict.full_clean(save=True)
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -965,132 +999,3 @@ class Command(BaseCommand):
BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", description="")
BanGroup.objects.create(name="Banned to subscribe", description="")
def _create_elections(
self,
groups: PopulatedGroups,
clubs: PopulatedClubs,
skia: User,
sli: User,
krophil: User,
):
"""Populate elections.
4 elections are created :
- one that has not started yet,
- one on the candidature period
- one on the vote period
- one that is finished
All elections have two lists, are linked to the AE and Troll clubs,
and have one role for each board role of thos two clubs, plus
an additional role linked to no club roles.
The ongoing vote and finished elections have candidates.
The finished election has 10 voters.
"""
def election_factory(title: str, start_candidature: datetime):
return Election(
title=title,
description="",
start_candidature=start_candidature,
end_candidature=start_candidature + timedelta(days=7),
start_date=start_candidature + timedelta(days=7),
end_date=start_candidature + timedelta(days=14),
)
# create the elections
elections = Election.objects.bulk_create(
[
election_factory("Election terminée", now() - timedelta(days=14)),
election_factory("Votes en cours", now() - timedelta(days=7)),
election_factory("Candidatures en cours", now()),
election_factory("Election à venir", now() + timedelta(days=7)),
]
)
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
# set the groups (all elections have the same groups)
groups.public.viewable_elections.set(elections)
clubs.ae.board_group.editable_elections.set(elections)
groups.subscribers.candidate_elections.set(elections)
groups.subscribers.votable_elections.set(elections)
# link elections to clubs (AE and Troll for all elections)
Election.clubs.through.objects.bulk_create(
[
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
*[
Election.clubs.through(club=clubs.troll, election=e)
for e in elections
],
]
)
# Create lists (all elections have two lists)
ElectionList.objects.bulk_create(
[
*[ElectionList(title="Candidat libre", election=e) for e in elections],
*[ElectionList(title="Troll", election=e) for e in elections],
]
)
# Create roles.
# Elections have a role for each board club role of AE and Troll,
# +an additional role linked to no club role
club_roles = list(
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
.select_related("club")
.order_by("club_id", "order")
)
Role.objects.bulk_create(
[
*[
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
for r in club_roles
for e in elections
],
*[Role(election=e, title="Rôle libre") for e in elections],
]
)
# create candidatures for ongoing_vote and finished elections
candidatures = []
lipsum = "\n\n".join(paragraphs(2))
for election in ongoing_vote, finished:
lists = list(election.election_lists.order_by("id"))
roles = list(election.roles.order_by("order")[:3])
candidatures.extend(
[
Candidature(
role=roles[0], user=skia, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[1], user=sli, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[2], user=krophil, election_list=lists[1], program=""
),
Candidature(
role=roles[2], user=sli, election_list=lists[0], program=lipsum
),
]
)
candidatures = Candidature.objects.bulk_create(candidatures)
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
votes = Vote.objects.bulk_create(
[
*[Vote(role=skia.role) for _ in range(6)],
*[Vote(role=sli_vp.role) for _ in range(8)],
*[Vote(role=krophil.role) for _ in range(9)],
]
)
skia.votes.set(votes[:6])
sli_vp.votes.set(votes[6:14])
krophil.votes.set(votes[14:20])
sli_treso.votes.set(votes[20:23])
finished.voters.set(list(User.objects.all()[:10]))
+132
View File
@@ -0,0 +1,132 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import mistune
from django.urls import reverse
from mistune import HTMLRenderer, Markdown
if TYPE_CHECKING:
from mistune import InlineParser, InlineState
# match __text__, without linebreak in the text, nor backslash prepending an underscore
# Examples :
# - "__text__" : OK
# - "__te xt__" : OK
# - "__te_xt__" : nope (underscore in the middle)
# - "__te\_xt__" : Ok (the middle underscore is escaped)
# - "__te\nxt__" : nope (there is a linebreak in the text)
# - "\__text__" : nope (one of the underscores have a backslash prepended)
# - "\\__text__" : Ok (the backslash is ignored, because there is another backslash before)
UNDERLINED_RE = (
r"(?<!\\)(?:\\{2})*" # ignore if there is an odd number of backslashes before
r"_{2}" # two underscores
r"(?P<underlined>([^\\_]|\\.)+)" # the actual text
r"_{2}" # closing underscores
)
SITH_LINK_RE = (
r"\[(?P<page_name>[\w\s]+)\]" # [nom du lien]
r"\(page:\/\/" # (page://
r"(?P<page_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
r"\)" # )
)
CUSTOM_DIMENSIONS_IMAGE_RE = (
r"\[(?P<img_name>[\w\s]+)\]" # [nom du lien]
r"\(img:\/\/" # (img://
r"(?P<img_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
r"\)" # )
)
def parse_underline(_inline: InlineParser, m: re.Match, state: InlineState):
state.append_token({"type": "underline", "raw": m.group("underlined")})
return m.end()
def underline(md_instance: Markdown):
md_instance.inline.register(
"underline",
UNDERLINED_RE,
parse_underline,
before="emphasis",
)
md_instance.renderer.register("underline", lambda _, text: f"<u>{text}</u>")
def parse_sith_link(_inline: InlineParser, m: re.Match, state: InlineState):
page_name = m.group("page_name")
page_slug = m.group("page_slug")
state.append_token(
{
"type": "link",
"children": [{"type": "text", "raw": page_name}],
"attrs": {"url": reverse("core:page", kwargs={"page_name": page_slug})},
}
)
return m.end()
def sith_link(md_instance: Markdown):
md_instance.inline.register(
"sith_link",
SITH_LINK_RE,
parse_sith_link,
before="emphasis",
)
# no custom renderer here.
# we just add another parsing rule, but render it as if it was
# a regular markdown link
class SithRenderer(HTMLRenderer):
def image(self, text: str, url: str, title=None) -> str:
if "?" not in url:
return super().image(text, url, title)
new_url, params = url.rsplit("?", maxsplit=1)
m = re.match(r"^(?P<width>\d+(%|px)?)(x(?P<height>\d+(%|px)?))?$", params)
if not m:
return super().image(text, url, title)
width, height = m.group("width"), m.group("height")
if not width.endswith(("%", "px")):
width += "px"
style = f"width:{width};"
if height is not None:
if not height.endswith(("%", "px")):
height += "px"
style += f"height:{height};"
return super().image(text, new_url, title).replace("/>", f'style="{style}" />')
markdown = mistune.create_markdown(
renderer=SithRenderer(escape=True),
plugins=[
underline,
sith_link,
"strikethrough",
"footnotes",
"table",
"spoiler",
"subscript",
"superscript",
"url",
],
)
@@ -1,13 +1,9 @@
# Generated by Django 4.2.17 on 2025-01-04 16:42
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def invert_is_manually_manageable(apps: StateApps, schema_editor):
"""Invert `is_manually_manageable`.
@@ -1,14 +1,10 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30
from typing import TYPE_CHECKING
import django.contrib.auth.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
from django.db.migrations.state import StateApps
def migrate_ban_groups(apps: StateApps, schema_editor):
+27
View File
@@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]
@@ -1,15 +1,11 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile")
@@ -0,0 +1,9 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0049_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
-33
View File
@@ -876,9 +876,6 @@ class SithFile(models.Model):
on_delete=models.SET_NULL,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta:
verbose_name = _("file")
@@ -887,24 +884,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
sas_id = settings.SITH_SAS_ROOT_DIR_ID
self.is_in_sas = self.id == sas_id or any(
p.id == sas_id for p in self.get_parent_list()
)
adding = self._state.adding
super().save(*args, **kwargs)
if adding:
self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
@@ -917,8 +900,6 @@ class SithFile(models.Model):
return user.is_board_member
if user.is_com_admin:
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool:
@@ -945,8 +926,6 @@ class SithFile(models.Model):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or (
self.parent is not None and self in self.get_parent_list()
):
@@ -1027,18 +1006,6 @@ class SithFile(models.Model):
def is_file(self):
return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self):
parents = []
current = self.parent
+4 -10
View File
@@ -19,16 +19,6 @@ from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)]
class ValidationErrorSchema(Schema):
class ValidationErrorItem(Schema):
loc: list[str | int]
msg: str
type: str
ctx: dict[str, str]
detail: list[ValidationErrorItem]
class UploadedImage(UploadedFile):
@classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any:
@@ -161,6 +151,10 @@ class UserFilterSchema(FilterSchema):
return value
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema):
godfather: int
godchild: int
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater<string>(expression);
const getMaxChoices = evaluateLater(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
@@ -54,7 +54,7 @@ export function limitedChoices(Alpine: AlpineType) {
});
}
effect(() => {
getMaxChoices((value) => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) {
+7 -26
View File
@@ -6,15 +6,10 @@
* for more efficient tree-shaking and gzip compression.
*/
// Must be loaded before Apline
import htmx from "htmx.org";
import "htmx.org/dist/ext/hx-alpine-compat.js";
import "htmx.org/dist/ext/hx-prompt.js";
import "htmx.org/dist/ext/hx-download.js";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
@@ -48,28 +43,14 @@ polyfillCountryFlagEmojis();
/**
* HTMX
*/
htmx.registerExtension("aria-busy", {
// biome-ignore lint/style/useNamingConvention: api's name
htmx_before_request: (
_: HTMLElement,
detail: { ctx: { target: HTMLElement | undefined } },
) => {
if (detail.ctx.target !== undefined) {
(detail.ctx.target as HTMLElement).ariaBusy = "true";
}
},
// biome-ignore lint/style/useNamingConvention: api's name
htmx_after_swap: (
_: HTMLElement,
detail: { ctx: { target: HTMLElement | undefined } },
) => {
if (detail.ctx.target !== undefined) {
(detail.ctx.target as HTMLElement).ariaBusy = null;
}
},
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => {
event.detail.target.ariaBusy = null;
});
htmx.config.transitions = true;
Object.assign(window, { htmx });
/**
@@ -4,9 +4,9 @@ import type {
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { inheritHtmlElement } from "#core:utils/web-components";
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
"max",
"min-characters-for-search",
];
public widget!: TomSelect;
public widget: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
@@ -24,8 +24,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
protected attributeChangedCallback(
name: string,
_oldValue: string,
newValue: string,
_oldValue?: string,
newValue?: string,
) {
switch (name) {
case "delay": {
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay ?? undefined,
loadThrottle: this.delay,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[];
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
/**
* A cache of researches that have been made using this input.
@@ -1,12 +1,11 @@
// @ts-expect-error TS2882
import "tom-select/dist/css/tom-select.default.css";
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
} from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import {
type GroupSchema,
groupSearchGroup,
@@ -1,14 +1,16 @@
// @ts-expect-error 2307
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css";
import { markdown } from "@ae_utbm/aemark";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror"; // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { type UploadUploadImageErrors, uploadUploadImage } from "#openapi";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
markdownRenderMarkdown,
type UploadUploadImageErrors,
uploadUploadImage,
} from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
const easymde = new EasyMDE({
@@ -23,11 +25,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
file: file,
},
});
if (response.response !== undefined && !response.response.ok) {
if (response?.response.status === 422) {
if (!response.response.ok) {
if (response.response.status === 422) {
onError(
(response.error as UploadUploadImageErrors[422]).detail
.map((err) => err.ctx.error)
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.join(" ; "),
);
} else if (response.response.status === 403) {
@@ -37,10 +39,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}
return;
}
if (response.data === undefined) {
// this can't happen, it's just for the type checker to know
return;
}
onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url)
@@ -60,7 +58,22 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
easymde.codemirror.replaceSelection("\n");
},
previewRender: (plainText) => markdown(plainText),
previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func(plainText, preview);
return null;
}, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
@@ -209,11 +222,9 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
const submits: HTMLInputElement[] = Array.from(
(textarea.closest("form") as HTMLFormElement).querySelectorAll(
'input[type="submit"]',
),
textarea.closest("form").querySelectorAll('input[type="submit"]'),
);
const parentDiv = textarea.parentElement?.parentElement as HTMLElement;
const parentDiv = textarea.parentElement.parentElement;
function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string
@@ -238,7 +249,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
};
@registerComponent("markdown-input")
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() {
super.connectedCallback();
@@ -2,7 +2,7 @@ import {
type InheritedHtmlElement,
inheritHtmlElement,
registerComponent,
} from "#core:utils/web-components";
} from "#core:utils/web-components.ts";
/**
* ElementOnce web components
@@ -28,7 +28,7 @@ export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
clearNode() {
while (this.firstChild) {
this.removeChild(this.lastChild as ChildNode);
this.removeChild(this.lastChild);
}
}
@@ -130,7 +130,7 @@ startObserver(observer);
export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`;
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
}
}
@@ -142,6 +142,6 @@ export class LinkOnce extends elementOnce("link") {
export class ScriptOnce extends inheritHtmlElement("script") {
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`;
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
}
}
@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
@registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") {
@@ -26,11 +26,9 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card"));
});
ndef.addEventListener("reading", (event) => {
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan");
this.node.value = (event as NDEFReadingEvent).serialNumber
.replace(/:/g, "")
.toUpperCase();
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
/* Auto submit form, we need another button to not trigger our previously defined click event */
const submit = document.createElement("button");
this.node.appendChild(submit);
@@ -1,6 +1,6 @@
import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components";
import { registerComponent } from "#core:utils/web-components.ts";
@registerComponent("ui-tab")
export class Tab extends HTMLElement {
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
}
if (name === "title") {
this.description = newValue ?? "";
this.description = newValue;
}
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
}
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
@registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement {
private node!: HTMLDivElement;
private node: HTMLDivElement;
connectedCallback() {
this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow");
this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event) => {
const target = (event as CustomEvent).detail as Tab;
this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
const target = event.detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) {
tab.setActive(false);
@@ -26,23 +26,19 @@ document.addEventListener("alpine:init", () => {
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
formContainer: undefined as unknown as HTMLElement,
nbForms: 0,
template: undefined as unknown as HTMLTemplateElement,
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
(
this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement
).setAttribute(":value", "nbForms");
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild as Element;
const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
@@ -63,7 +59,7 @@ document.addEventListener("alpine:init", () => {
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form = this.formContainer.children[i];
const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
+3 -6
View File
@@ -1,5 +1,5 @@
function showMenu() {
const navbar = document.getElementById("navbar-content") as HTMLElement;
const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
}
@@ -20,12 +20,9 @@ function navbarInit() {
item.removeAttribute("open");
}
});
item.addEventListener("click", (event) => {
item.addEventListener("click", (event: MouseEvent) => {
// Don't close when clicking on desktop mode
if (
(event.target as HTMLElement).nodeName !== "SUMMARY" ||
(event as MouseEvent).detail === 0
) {
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
return;
}
@@ -1,4 +1,3 @@
// @ts-expect-error 2307 this dependency does exist, but it's a little bit wacky
import clip from "@arendjr/text-clipper";
/*
+7 -8
View File
@@ -70,16 +70,15 @@ function createTooltip(element: HTMLElement) {
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip") as string;
tooltip.innerText = element.getAttribute("tooltip");
for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] },
]) {
const populated = attributes.default;
const attr = element.getAttribute(attributes.src);
if (attr !== null) {
populated.push(...attr.split(" "));
if (element.hasAttribute(attributes.src)) {
populated.push(...element.getAttribute(attributes.src).split(" "));
}
tooltip.setAttribute(attributes.dst, populated.join(" "));
}
@@ -94,7 +93,7 @@ function getTooltip(element: HTMLElement) {
return tooltip;
}
function tooltipMouseover(event: Event) {
function tooltipMouseover(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -112,7 +111,7 @@ function tooltipMouseover(event: Event) {
});
}
function tooltipMouseout(event: Event) {
function tooltipMouseout(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -142,7 +141,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
}
} else if (tooltips.has(target)) {
// Remove corresponding tooltip
tooltips.get(target)?.remove();
tooltips.get(target).remove();
tooltips.delete(target);
}
}
@@ -164,7 +163,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
continue;
}
if (tooltips.has(target)) {
tooltips.get(target)?.remove();
tooltips.get(target).remove();
tooltips.delete(target);
}
}
+1
View File
@@ -55,6 +55,7 @@ declare interface NDEFRecordInit {
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFReader: NDEFReader;
+23 -18
View File
@@ -1,13 +1,20 @@
import cytoscape, { type ElementDefinition, type Singular } from "cytoscape";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
type GraphData = { data: UserProfileSchema | { source: number; target: number } }[];
type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() {
return window.innerWidth < 500;
@@ -20,8 +27,10 @@ async function getGraphData(
): Promise<GraphData> {
const data = (
await familyGetFamilyGraph({
// biome-ignore lint/style/useNamingConvention: api is snake_case
path: { user_id: userId },
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
@@ -30,10 +39,6 @@ async function getGraphData(
},
})
).data;
if (data === undefined) {
console.error("Family graph request failed");
return [];
}
return [
...data.users.map((user) => {
return { data: user };
@@ -150,7 +155,7 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank")?.focus();
window.open(el.data().profile_url, "_blank").focus();
},
},
@@ -190,12 +195,12 @@ document.addEventListener("alpine:init", () => {
godfathersDepth: 0,
godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as unknown as cytoscape.Core,
graphData: {} as GraphData,
graph: undefined as cytoscape.Core,
graphData: {},
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop) as string, 10);
const value = Number.parseInt(initialUrlParams.get(prop), 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
@@ -218,8 +223,8 @@ document.addEventListener("alpine:init", () => {
await delayedFetch();
});
}
this.$watch("reverse", async (newValue, _oldValue) => {
updateQueryString("reverse", newValue.toString(), History.Replace);
this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", value.toString(), History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
@@ -254,9 +259,9 @@ document.addEventListener("alpine:init", () => {
},
async reverseGraph() {
this.graph
.elements()
.positions((el, _) => ({ x: -el.position().x, y: -el.position().y }));
this.graph.elements((el: NodeSingular) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
+2 -2
View File
@@ -5,9 +5,9 @@ interface AlertParams {
export class AlertMessage {
public open: boolean;
public success!: boolean;
public success: boolean;
public content: string;
private timeoutId: number | null;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
+5 -8
View File
@@ -37,10 +37,6 @@ export const paginated = async <T>(
queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data;
if (firstPage === undefined) {
console.error(`Request to "${options?.url}" failed`);
return [];
}
const results = firstPage.results;
const nbElements = firstPage.count;
@@ -49,9 +45,9 @@ export const paginated = async <T>(
if (nbPages > 1) {
const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>;
const nextPage = structuredClone(queryParams);
nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data?.results ?? []));
promises.push(endpoint(nextPage).then((res) => res.data.results));
}
results.push(...(await Promise.all(promises)).flat());
}
@@ -65,7 +61,8 @@ interface Request extends TDataShape {
interface InterceptorOptions {
url: string;
}
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>;
@@ -74,7 +71,7 @@ export type GenericEndpoint = <ThrowOnError extends boolean = false>(
**/
export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = "";
const interceptor = (_request: Request, options: InterceptorOptions) => {
const interceptor = (_request: undefined, options: InterceptorOptions) => {
url = options.url;
throw new Error("We don't want to send the request");
};
+9 -12
View File
@@ -7,14 +7,14 @@ interface StringifyOptions<T extends object> {
titleRow?: readonly string[];
}
function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) {
const path = key.split(".");
let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined;
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
let res = obj[path.shift() as keyof T];
for (const node of path) {
if (res === undefined) {
if (res === null) {
break;
}
res = res[node] as { [key: string]: unknown } | undefined;
res = res[node];
}
return res;
}
@@ -29,21 +29,18 @@ function sanitizeCell(content: string): string {
}
export const csv = {
stringify: <T extends { [key: string]: unknown }>(
objs: T[],
options?: StringifyOptions<T>,
) => {
const columns = options?.columns;
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
const columns = options.columns;
const content = objs
.map((obj) => {
return (columns ?? [])
return columns
.map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString());
})
.join(",");
})
.join("\n");
if (!options?.titleRow) {
if (!options.titleRow) {
return content;
}
const firstRow = options.titleRow.map(sanitizeCell).join(",");
+2 -3
View File
@@ -29,7 +29,6 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement {
readonly inheritedTagName: K;
// readonly initializedAttribute: "component-initialized";
node: HTMLElementTagNameMap[K];
}
@@ -48,8 +47,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
readonly initializedAttribute = "component-initialized";
node!: HTMLElementTagNameMap[K];
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice
-4
View File
@@ -46,10 +46,6 @@ details.accordion>.accordion-content {
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
overflow: hidden;
@media screen and (max-width: 600px) {
padding: .75em 1.5em;
}
}
@mixin animation($selector) {
+1 -6
View File
@@ -29,12 +29,7 @@
align-items: center;
gap: 20px;
&:disabled {
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
&.clickable:hover {
background-color: darken($primary-neutral-light-color, 5%);
}
+2 -1
View File
@@ -23,7 +23,7 @@
border-radius: 5px;
color: black;
&:not(.link-like):not(:disabled):hover {
&:hover {
background: hsl(0, 0%, 83%);
}
}
@@ -141,6 +141,7 @@ form {
display: block;
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
line-height: 1;
white-space: nowrap;
.fields-centered {
padding: 10px 10px 0;
+2 -2
View File
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
justify-content: center;
}
a.button {
>.button {
box-sizing: border-box;
height: 35px;
background-color: transparent;
@@ -139,7 +139,7 @@ $background-color-hovered: #283747;
font-size: .9em;
width: 120px;
&:not(.link-like):not(:disabled):hover {
&:hover {
background-color: $background-color-hovered;
}
}
-4
View File
@@ -4,10 +4,6 @@
<div id="page">
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
<blockquote>
{% trans %}Impossible, perhaps the archives are incomplete{% endtrans %}
</blockquote>
</div>
{% endblock %}
-2
View File
@@ -14,8 +14,6 @@
{%- endblock %}"
>
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:locale:alternate" content="en_GB" />
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
+8 -3
View File
@@ -22,9 +22,14 @@
</form>
<ul class="bars">
{% cache 100 "counters_activity" %}
{# It would be cleaner to handle the timeout with django-celery-beat,
but doing it here is simpler and less error-prone #}
{% do Counter.objects.filter(type="BAR").handle_timeout() %}
{# The sith has no periodic tasks manager
and using cron jobs would be way too overkill here.
Thus the barmen timeout is handled in the only place that
is loaded on every page : the header bar.
However, let's be clear : this has nothing to do here.
It's' merely a contrived workaround that should
replaced by a proper task manager as soon as possible. #}
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
{% endcache %}
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
<li>
+1 -1
View File
@@ -10,7 +10,7 @@
<template x-for="(message, index) in $notifications.getAll()">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)">
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
<i class="fa fa-close"></i>
</span>
</div>
+31 -29
View File
@@ -4,11 +4,15 @@
{% trans %}Delete confirmation{% endtrans %}
{% endblock %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% if is_fragment %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% endif %}
{% block file %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
@@ -19,32 +23,30 @@
{% set action = current %}
{% endif %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<form
method="post"
{% if is_fragment %}
hx-action="{{ action }}"
hx-target="#content"
hx-swap="innerHTML"
{% else %}
action="{{ action }}"
{% endif %}
>
<form action="{{ action }}" method="post">
{% csrf_token %}
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form>
<form
method="get"
{% if is_fragment %}
hx-action="{{ previous }}"
hx-target="#content"
hx-swap="innerHTML"
{% else %}
action="javascript:history.back();"
{% endif %}
>
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" />
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<button
{% if is_fragment %}
hx-post="{{ action }}"
hx-target="#content"
hx-swap="outerHtml"
{% endif %}
>{% trans %}Confirm{% endtrans %}</button>
<button
{% if is_fragment %}
hx-get="{{ previous }}"
hx-target="#content"
hx-swap="outerHtml"
{% else %}
action="window.history.back()"
{% endif %}
>{% trans %}Cancel{% endtrans %}</button>
</form>
{% endblock %}
+2 -2
View File
@@ -39,13 +39,13 @@
<p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
hx-target="#content"
hx-swap="innerHTML"
hx-swap="outerHtml"
>{% trans %}Moderate{% endtrans %}</button> -
{% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %}
<button
hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}"
hx-target="#file-{{ loop.index }}"
hx-swap="innerHTML"
hx-swap="outerHtml"
>{% trans %}Delete{% endtrans %}</button></p>
</div>
{% endfor %}
@@ -1,6 +1,6 @@
<form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disable="find input[type='submit']"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
>
{% for message in messages %}
+2 -4
View File
@@ -20,15 +20,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from typing import TYPE_CHECKING, Callable
from typing import Callable
import honeypot.templatetags.honeypot as honeypot_filters
from django.template.loader import render_to_string
from jinja2 import Environment, nodes
from jinja2.ext import Extension
if TYPE_CHECKING:
from jinja2.parser import Parser
from jinja2.parser import Parser
class HoneypotExtension(Extension):
+2 -4
View File
@@ -23,17 +23,15 @@
#
import datetime
from typing import TYPE_CHECKING
import phonenumbers
from aemark import markdown as md
from django import template
from django.forms import BoundField
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
from django.utils.translation import ngettext
if TYPE_CHECKING:
from django.forms import BoundField
from core.markdown import markdown as md
register = template.Library()
+12 -9
View File
@@ -18,7 +18,6 @@ from smtplib import SMTPException
import freezegun
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
@@ -35,6 +34,7 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Club
from core.baker_recipes import subscriber_user
from core.markdown import markdown
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.views import AllowFragment
@@ -200,28 +200,31 @@ class TestUserLogin:
[
(
"[nom du lien](page://nomDeLaPage)",
'<a href="/page/nomDeLaPage">nom du lien</a>',
'<a href="/page/nomDeLaPage/">nom du lien</a>',
),
("__texte__", "<u>texte</u>"),
("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"),
(
'![tst_alt](/img.png?50% "tst_title")',
'<img src="/img.png" style="width:50%" alt="tst_alt" title="tst_title" />',
'<img src="/img.png" alt="tst_alt" title="tst_title" style="width:50%;" />',
),
(
"[texte](page://tst-page)",
'<a href="/page/tst-page/">texte</a>',
),
("[texte](page://tst-page)", '<a href="/page/tst-page">texte</a>'),
(
"![](/img.png?50x450)",
'<img src="/img.png" style="width:50px;height:450px" alt="" />',
'<img src="/img.png" alt="" style="width:50px;height:450px;" />',
),
("![](/img.png)", '<img src="/img.png" alt="" />'),
(
"![](/img.png?50%x120%)",
'<img src="/img.png" style="width:50%;height:120%" alt="" />',
'<img src="/img.png" alt="" style="width:50%;height:120%;" />',
),
("![](/img.png?50px)", '<img src="/img.png" style="width:50px" alt="" />'),
("![](/img.png?50px)", '<img src="/img.png" alt="" style="width:50px;" />'),
(
"![](/img.png?50pxx120%)",
'<img src="/img.png" style="width:50px;height:120%" alt="" />',
'<img src="/img.png" alt="" style="width:50px;height:120%;" />',
),
# when the image dimension has a wrong format, don't touch the url
("![](/img.png?50pxxxxxxxx)", '<img src="/img.png?50pxxxxxxxx" alt="" />'),
@@ -347,7 +350,7 @@ http://git.an
<p><a href="http://git.an">http://git.an</a></p>
<h1>Swag</h1>
<p>&lt;guy&gt;Bibou&lt;/guy&gt;</p>
&lt;script&gt;alert('Guy');&lt;/script&gt;
<p>&lt;script&gt;alert('Guy');&lt;/script&gt;</p>
"""
assertInHTML(expected, response.text)
+41 -11
View File
@@ -5,6 +5,7 @@ from typing import Callable
from uuid import uuid4
import pytest
from django.conf import settings
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase
@@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture
from sith import settings
@pytest.mark.django_db
@@ -30,24 +31,19 @@ class TestImageAccess:
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
],
)
def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image."""
user = user_factory()
picture: SithFile = baker.make(
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
)
assert picture.is_owned_by(user)
picture = picture_recipe.make()
assert user.can_edit(picture)
def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it."""
user = baker.make(User)
picture: Picture = baker.make(Picture, owner=user)
assert picture.is_owned_by(user)
picture = picture_recipe.make(owner=user)
assert user.can_edit(picture)
@pytest.mark.parametrize(
"user_factory",
@@ -63,7 +59,41 @@ class TestImageAccess:
user = user_factory()
owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner)
assert not picture.is_owned_by(user)
assert not user.can_edit(picture)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
+2 -5
View File
@@ -1,12 +1,11 @@
from datetime import timedelta
from typing import TYPE_CHECKING
import freezegun
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
@@ -14,11 +13,9 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
class TestEditPage:
+4 -25
View File
@@ -27,6 +27,7 @@ from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase):
@@ -34,7 +35,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
SithFile.objects.all().delete()
Picture.objects.all().delete() # same for pictures
User.objects.all().delete()
user_recipe = Recipe(
User,
@@ -141,22 +142,6 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search"))
assert response.status_code == 200
def test_search_with_whitelist_unique(self):
"""Test that when a user has a whitelist and appears in the results,
it appears only once.
This is a regression test (cf #1463)
"""
user = subscriber_user.make(is_viewable=False)
user.whitelisted_users.add(
*subscriber_user.make(_quantity=4, _bulk_create=True)
)
self.client.force_login(user)
response = self.client.get(
reverse("core:search", query={"query": user.last_name})
)
assert response.context_data["users"] == [user]
@pytest.mark.django_db
def test_user_account_not_found(client: Client):
@@ -216,11 +201,7 @@ class TestFilterInactive(TestCase):
]
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
baker.make(
Refilling,
customer=cls.users[4].customer,
date=time_active,
counter=counter,
amount=1,
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
)
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
@@ -475,9 +456,7 @@ def test_user_preferences(client: Client):
@pytest.mark.django_db
def test_user_stats(client: Client):
user = subscriber_user.make()
baker.make(
Refilling, customer=user.customer, amount=settings.SITH_ACCOUNT_MAX_MONEY
)
baker.make(Refilling, customer=user.customer, amount=99999)
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make(
Permanency,
+22 -6
View File
@@ -21,7 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.urls import path, register_converter
from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView
from com.views import NewsListView
@@ -193,11 +193,27 @@ urlpatterns = [
name="user_gift_delete",
),
# File views
path("file/", FileListView.as_view(), name="file_list"),
path("file/<int:file_id>/", FileView.as_view(), name="file_detail"),
path("file/<int:file_id>/edit/", FileEditView.as_view(), name="file_edit"),
path("file/<int:file_id>/prop/", FileEditPropView.as_view(), name="file_prop"),
path("file/<int:file_id>/delete/", FileDeleteView.as_view(), name="file_delete"),
re_path(r"^file/$", FileListView.as_view(), name="file_list"),
re_path(
r"^file/(?P<file_id>[0-9]+)/$",
FileView.as_view(),
name="file_detail",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/edit/$",
FileEditView.as_view(),
name="file_edit",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/prop/$",
FileEditPropView.as_view(),
name="file_prop",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/delete/$",
FileDeleteView.as_view(),
name="file_delete",
),
path("file/moderation/", FileModerationView.as_view(), name="file_moderation"),
path(
"file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
+77 -6
View File
@@ -12,23 +12,26 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from dataclasses import dataclass
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import TYPE_CHECKING, Final
from typing import Any, Final, Unpack
import PIL
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import models
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.utils.timezone import localdate
from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
@@ -43,6 +46,21 @@ to generate a dummy image that is considered valid nonetheless
"""
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester.
@@ -190,3 +208,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip
return None
Filterable = type[models.Model] | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list
+11 -7
View File
@@ -13,7 +13,7 @@
#
#
import mimetypes
from typing import TYPE_CHECKING
from pathlib import Path
from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model
@@ -48,9 +48,6 @@ from core.views.widgets.ajax_select import (
)
from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from pathlib import Path
def send_raw_file(path: Path) -> HttpResponse:
"""Send a file located in the MEDIA_ROOT
@@ -356,8 +353,15 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
if "next" in self.request.GET:
return self.request.GET["next"]
if self.object.parent is None:
return reverse("core:file_list")
return reverse("core:file_detail", kwargs={"file_id": self.object.parent.id})
return reverse(
"core:file_list",
)
return reverse(
"core:file_detail",
kwargs={
"file_id": self.object.parent.id,
},
)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
@@ -370,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView):
model = SithFile
template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
queryset = SithFile.objects.filter(is_moderated=False)
ordering = "id"
paginate_by = 100
+1 -4
View File
@@ -22,8 +22,8 @@
#
import re
from copy import copy
from datetime import date, datetime
from io import BytesIO
from typing import TYPE_CHECKING
from captcha.fields import CaptchaField
from django import forms
@@ -59,9 +59,6 @@ from core.views.widgets.ajax_select import (
)
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from datetime import date, datetime
# Widgets
+2 -7
View File
@@ -22,11 +22,11 @@
#
#
from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import F
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import ListView, TemplateView
@@ -34,10 +34,6 @@ from club.models import Club
from core.models import Notification, User
from core.schemas import UserFilterSchema
if TYPE_CHECKING:
from django.db.models.query import QuerySet
from django.http import HttpRequest
class NotificationList(LoginRequiredMixin, ListView):
model = Notification
@@ -69,7 +65,6 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
.distinct()
)
clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
+3 -5
View File
@@ -1,16 +1,14 @@
import copy
import inspect
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import SafeString
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
+1 -4
View File
@@ -27,7 +27,6 @@ from datetime import timedelta
# This file contains all the views that concern the user model
from operator import itemgetter
from smtplib import SMTPException
from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.auth import login, views
@@ -44,6 +43,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django.views.generic import (
@@ -73,9 +73,6 @@ from counter.models import Refilling, Selling
from eboutic.models import Invoice
from trombi.views import UserTrombiForm
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class SithLoginView(views.LoginView):
"""The login View."""
+4 -7
View File
@@ -1,18 +1,15 @@
from typing import TYPE_CHECKING, Any
from collections.abc import Collection
from typing import Any
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Model, QuerySet
from django.forms import Select, SelectMultiple
from ninja import ModelSchema
from pydantic import TypeAdapter
from core.models import Group, SithFile, User
from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema
if TYPE_CHECKING:
from collections.abc import Collection
from django.db.models import Model, QuerySet
from ninja import ModelSchema
class AutoCompleteSelectMixin:
component_name = "autocomplete-select"
+4 -50
View File
@@ -1,68 +1,22 @@
from decimal import Decimal
from django.conf import settings
from django.core import checks
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.functional import cached_property
class CurrencyField(models.DecimalField):
"""Custom database field used for currency."""
def __init__(
self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs
):
kwargs.update({"max_digits": 12, "decimal_places": 2})
self.min_value = min_value
self.max_value = max_value
super().__init__(verbose_name, name, **kwargs)
def __init__(self, *args, **kwargs):
kwargs["max_digits"] = 12
kwargs["decimal_places"] = 2
super().__init__(*args, **kwargs)
def to_python(self, value):
if value is None:
return None
return super().to_python(value).quantize(Decimal("0.01"))
@cached_property
def validators(self):
res = []
if self.max_value:
res.append(MaxValueValidator(self.max_value))
if self.min_value:
res.append(MinValueValidator(self.min_value))
return [*super().validators, *res]
def check(self, **kwargs): # pragma: no cover
# this is executed during runserver, but won't run in prod
errors = super().check(**kwargs)
for name, val in ("min_value", self.min_value), ("max_value", self.max_value):
if not val:
continue
try:
float(val)
except ValueError:
errors.append(
checks.Error(
f"CurrencyField.{name} must be a valid float",
obj=self,
id="sith.E001",
)
)
return errors
def formfield(self, **kwargs):
return super().formfield(
**{"min_value": self.min_value, "max_value": self.max_value, **kwargs}
)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.min_value is not None:
kwargs["min_value"] = self.min_value
if self.max_value is not None:
kwargs["max_value"] = self.max_value
return name, path, args, kwargs
if settings.TESTING:
from model_bakery import baker
+38 -92
View File
@@ -3,15 +3,12 @@ import math
import uuid
from collections import defaultdict
from datetime import date, datetime, timezone
from typing import TYPE_CHECKING, ClassVar
from dateutil.relativedelta import relativedelta
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule
@@ -20,7 +17,6 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User, UserQuerySet
from core.views import LoginForm
from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
@@ -41,7 +37,6 @@ from counter.models import (
Customer,
Eticket,
InvoiceCall,
Permanency,
Price,
Product,
ProductFormula,
@@ -58,9 +53,6 @@ from counter.widgets.ajax_select import (
AutoCompleteSelectProduct,
)
if TYPE_CHECKING:
from django.http import HttpRequest
class BillingInfoForm(forms.ModelForm):
class Meta:
@@ -99,18 +91,30 @@ class StudentCardForm(forms.ModelForm):
class GetUserForm(forms.Form):
"""Find a user to show its click page."""
"""The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
some nickname, first name, or last name (TODO)
"""
code = forms.CharField(
label="Code",
max_length=StudentCard.UID_SIZE,
required=False,
widget=NFCTextInput(attrs={"autofocus": True}),
widget=NFCTextInput,
)
id = forms.CharField(
label=_("Select user"), widget=AutoCompleteSelectUser, required=False
label=_("Select user"),
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
)
def as_p(self):
self.fields["code"].widget.attrs["autofocus"] = True
return super().as_p()
def clean(self):
cleaned_data = super().clean()
customer = None
@@ -132,41 +136,11 @@ class GetUserForm(forms.Form):
if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = customer.user_id
cleaned_data["user_id"] = customer.user.id
cleaned_data["user"] = customer.user
return cleaned_data
class CounterLoginForm(LoginForm):
"""LoginForm to log a barman in a counter.
To be able to log in a counter, a user must :
- be part of the sellers of the given counter
- not being already logged in any counter
"""
def __init__(self, *args, request: HttpRequest, counter: Counter, **kwargs):
super().__init__(*args, **kwargs)
self.counter = counter
self.request = request
def confirm_login_allowed(self, user: User):
super().confirm_login_allowed(user)
if not self.counter.sellers.contains(user):
raise ValidationError(
message=_("You are not a barman of this counter."), code="not_barman"
)
if Permanency.objects.filter(end=None, user=user).exists():
if user in self.request.barmen:
message = _("You are already logged in this counter.")
elif user in self.counter.barmen_list:
message = _("You are already logged in another counter.")
else:
message = _("You are already logged on another device")
raise ValidationError(message=message, code="already_logged_in")
class RefillForm(forms.ModelForm):
allowed_refilling_methods = [
Refilling.PaymentMethod.CASH,
@@ -175,19 +149,18 @@ class RefillForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
amount = forms.FloatField(
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
)
class Meta:
model = Refilling
fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(
self, *args, counter: Counter, operator: User, customer: Customer, **kwargs
):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
max_value = settings.SITH_ACCOUNT_MAX_MONEY - customer.amount
# server-side max_value validation is done by Refilling.clean
self.fields["amount"].widget.attrs["max"] = max_value
self.fields["payment_method"].choices = (
method
for method in self.fields["payment_method"].choices
@@ -195,9 +168,6 @@ class RefillForm(forms.ModelForm):
)
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
self.instance.counter = counter
self.instance.operator = operator
self.instance.customer = customer
class CounterEditForm(forms.ModelForm):
@@ -439,7 +409,6 @@ class ProductForm(forms.ModelForm):
"club",
"limit_age",
"tray",
"clic_limit",
"archived",
]
help_texts = {
@@ -571,7 +540,16 @@ class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True)
price_id = forms.IntegerField(min_value=0, required=True)
def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs):
def __init__(
self,
customer: Customer,
counter: Counter,
allowed_prices: dict[int, Price],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs)
@@ -606,15 +584,6 @@ class BasketItemForm(forms.Form):
class BaseBasketForm(forms.BaseFormSet):
# Minimum amount of money there must be on the account after the transaction
# If None, the min balance check is skipped
min_result_balance: ClassVar[int | None] = 0
def __init__(self, *args, customer: Customer, counter: Counter, **kwargs):
super().__init__(*args, **kwargs)
self.customer = customer
self.counter = counter
def clean(self):
self.forms = [form for form in self.forms if form.cleaned_data != {}]
@@ -623,8 +592,8 @@ class BaseBasketForm(forms.BaseFormSet):
self._check_forms_have_errors()
self._check_product_are_unique()
self._check_recorded_products()
self._check_account_balance()
self._check_recorded_products(self[0].customer)
self._check_enough_money(self[0].counter, self[0].customer)
def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self):
@@ -635,35 +604,12 @@ class BaseBasketForm(forms.BaseFormSet):
if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries."))
@cached_property
def total_price(self):
refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
total_other = sum(
form.cleaned_data["total_price"]
for form in self.forms
if form.price.product.product_type_id != refill
)
total_refill = sum(
form.cleaned_data["total_price"]
for form in self.forms
if form.price.product.product_type_id == refill
)
return total_other - total_refill
def _check_account_balance(self):
result_balance = self.customer.amount - self.total_price
if (
self.min_result_balance is not None
and self.min_result_balance > result_balance
):
def _check_enough_money(self, counter: Counter, customer: Customer):
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
if self.total_price > customer.amount:
raise forms.ValidationError(_("Not enough money"))
if result_balance > settings.SITH_ACCOUNT_MAX_MONEY:
raise ValidationError(
_("There cannot be more than %(money)d€ on an AE account")
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
)
def _check_recorded_products(self):
def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers"""
items = defaultdict(int)
for form in self.forms:
@@ -672,7 +618,7 @@ class BaseBasketForm(forms.BaseFormSet):
returnables = list(
ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
).annotate_balance_for(self.customer)
).annotate_balance_for(customer)
)
limit_reached = []
for returnable in returnables:
+1 -4
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from operator import attrgetter
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.mail import send_mass_mail
@@ -13,9 +13,6 @@ from django.utils.translation import gettext as _
from core.models import User, UserQuerySet
from counter.models import AccountDump, Counter, Customer, Product, Selling
if TYPE_CHECKING:
from collections.abc import Iterable
class Command(BaseCommand):
"""Effectively dump the inactive users.
-67
View File
@@ -1,67 +0,0 @@
from typing import TYPE_CHECKING, Callable
from django.utils.functional import SimpleLazyObject
from counter.models import Permanency
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
from django.http import HttpRequest, HttpResponse
from core.models import User
SESSION_PERMANENCES_KEY = "permanence_ids"
def get_cached_barmen(request: HttpRequest) -> set[User]:
if not hasattr(request, "_cached_barmen"):
session: SessionBase = request.session
if session_ids := session.get(SESSION_PERMANENCES_KEY, None):
# Get ongoing permanences which id is in session.
# Note : we store permanence ids rather than user id to be sure
# not to wrongfully mark someone as logged here,
# even if it logged out then logged in elsewhere.
permanences = (
Permanency.objects.filter(end=None, id__in=session_ids)
.order_by("id")
.select_related("user")
)
# if the list of permanences occurring on this device has changed
# since the last page load, change the ids stored in session
real_ids = [p.id for p in permanences]
if real_ids != session_ids:
session[SESSION_PERMANENCES_KEY] = real_ids
request._cached_barmen = {p.user for p in permanences}
else:
request._cached_barmen = set()
return request._cached_barmen
class BarmenMiddleware:
"""Inject barmen logged in the current session.
In a similar fashion as `request.user`, `request.barmen` contains
users that are barmen in the current session, and ONLY them ;
if a user is logged as a barman on another session,
it will not be in `request.barmen`.
Notes:
In case of ended permanence, users will be automatically
removed from `request.barmen`.
However, in case of newly started permanence, this middleware
cannot add new barmen in the session data, so that operation
must be explicitly done in the barman login view.
"""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest):
request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request))
return self.get_response(request)
@@ -1,14 +1,11 @@
# Generated by Django 4.2.17 on 2024-12-08 13:30
from operator import attrgetter
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Count
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def delete_duplicates(apps: StateApps, schema_editor):
"""Delete cards of users with more than one student cards.
@@ -1,11 +1,7 @@
# Generated by Django 4.2.17 on 2024-12-15 17:53
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
from django.db.migrations.state import StateApps
def move_priority_to_order(apps: StateApps, schema_editor):
@@ -1,13 +1,9 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59
from typing import TYPE_CHECKING
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it

Some files were not shown because too many files have changed in this diff Show More