diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 08e322ca..51b4f75d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.4 + rev: v0.15.0 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@1.9.4"] + additional_dependencies: ["@biomejs/biome@2.3.14"] - repo: https://github.com/rtts/djhtml rev: 3.0.10 hooks: diff --git a/biome.json b/biome.json index cf528680..de2077a9 100644 --- a/biome.json +++ b/biome.json @@ -7,20 +7,34 @@ }, "files": { "ignoreUnknown": false, - "ignore": ["*.min.*", "staticfiles/generated"] + "includes": ["**/static/**"] }, "formatter": { "enabled": true, "indentStyle": "space", "lineWidth": 88 }, - "organizeImports": { - "enabled": true - }, "linter": { "enabled": true, "rules": { - "all": true + "recommended": true, + "style": { + "useNamingConvention": "error" + }, + "performance": { + "noNamespaceImport": "error" + }, + "suspicious": { + "noConsole": { + "level": "error", + "options": { "allow": ["error", "warn"] } + } + }, + "correctness": { + "noUnusedVariables": "error", + "noUndeclaredVariables": "error", + "noUndeclaredDependencies": "error" + } } }, "javascript": { diff --git a/club/models.py b/club/models.py index 3c0f720f..f6695812 100644 --- a/club/models.py +++ b/club/models.py @@ -26,7 +26,6 @@ from __future__ import annotations from typing import Iterable, Self from django.conf import settings -from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import RegexValidator, validate_email from django.db import models, transaction @@ -187,9 +186,6 @@ class Club(models.Model): self.page.save(force_lock=True) def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: - # Invalidate the cache of this club and of its memberships - for membership in self.members.ongoing().select_related("user"): - cache.delete(f"membership_{self.id}_{membership.user.id}") self.board_group.delete() self.members_group.delete() return super().delete(*args, **kwargs) @@ -210,24 +206,15 @@ class Club(models.Model): """Method to see if that object can be edited by the given user.""" return self.has_rights_in_club(user) - def get_membership_for(self, user: User) -> Membership | None: - """Return the current membership the given user. + @cached_property + def current_members(self) -> list[Membership]: + return list(self.members.ongoing().select_related("user").order_by("-role")) - Note: - The result is cached. - """ + def get_membership_for(self, user: User) -> Membership | None: + """Return the current membership of the given user.""" if user.is_anonymous: return None - membership = cache.get(f"membership_{self.id}_{user.id}") - if membership == "not_member": - return None - if membership is None: - membership = self.members.filter(user=user, end_date=None).first() - if membership is None: - cache.set(f"membership_{self.id}_{user.id}", "not_member") - else: - cache.set(f"membership_{self.id}_{user.id}", membership) - return membership + return next((m for m in self.current_members if m.user_id == user.id), None) def has_rights_in_club(self, user: User) -> bool: return user.is_in_group(pk=self.board_group_id) @@ -245,7 +232,7 @@ class MembershipQuerySet(models.QuerySet): are included, even if there are no more members. If you want to get the users who are currently in the board, - mind combining this with the :meth:`ongoing` queryset method + mind combining this with the `ongoing` queryset method """ return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) @@ -288,42 +275,29 @@ class MembershipQuerySet(models.QuerySet): ) def update(self, **kwargs) -> int: - """Refresh the cache and edit group ownership. - - Update the cache, when necessary, remove - users from club groups they are no more in + """Remove users from club groups they are no more in and add them in the club groups they should be in. Be aware that this adds three db queries : - one to retrieve the updated memberships, - one to perform group removal and one to perform - group attribution. + + - one to retrieve the updated memberships + - one to perform group removal + - and one to perform group attribution. """ nb_rows = super().update(**kwargs) if nb_rows == 0: - # if no row was affected, no need to refresh the cache + # if no row was affected, no need to edit club groups return 0 - cache_memberships = {} memberships = set(self.select_related("club")) # delete all User-Group relations and recreate the necessary ones - # It's more concise to write and more reliable Membership._remove_club_groups(memberships) Membership._add_club_groups(memberships) - for member in memberships: - cache_key = f"membership_{member.club_id}_{member.user_id}" - if member.end_date is None: - cache_memberships[cache_key] = member - else: - cache_memberships[cache_key] = "not_member" - cache.set_many(cache_memberships) return nb_rows def delete(self) -> tuple[int, dict[str, int]]: """Work just like the default Django's delete() method, - but add a cache invalidation for the elements of the queryset - before the deletion, - and a removal of the user from the club groups. + but also remove the concerned users from the club groups. Be aware that this adds some db queries : @@ -339,12 +313,6 @@ class MembershipQuerySet(models.QuerySet): nb_rows, rows_counts = super().delete() if nb_rows > 0: Membership._remove_club_groups(memberships) - cache.set_many( - { - f"membership_{m.club_id}_{m.user_id}": "not_member" - for m in memberships - } - ) return nb_rows, rows_counts @@ -408,9 +376,6 @@ class Membership(models.Model): self._remove_club_groups([self]) if self.end_date is None: self._add_club_groups([self]) - cache.set(f"membership_{self.club_id}_{self.user_id}", self) - else: - cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member") def get_absolute_url(self): return reverse("club:club_members", kwargs={"club_id": self.club_id}) @@ -431,7 +396,6 @@ class Membership(models.Model): def delete(self, *args, **kwargs): self._remove_club_groups([self]) super().delete(*args, **kwargs) - cache.delete(f"membership_{self.club_id}_{self.user_id}") @staticmethod def _remove_club_groups( diff --git a/club/schemas.py b/club/schemas.py index 5a7ccccb..9483d4c6 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -1,18 +1,16 @@ from typing import Annotated -from annotated_types import MinLen from django.db.models import Q -from ninja import Field, FilterSchema, ModelSchema +from ninja import FilterLookup, FilterSchema, ModelSchema from club.models import Club, Membership -from core.schemas import SimpleUserSchema +from core.schemas import NonEmptyStr, SimpleUserSchema class ClubSearchFilterSchema(FilterSchema): - search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains") + search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None is_active: bool | None = None parent_id: int | None = None - parent_name: str | None = Field(None, q="parent__name__icontains") exclude_ids: set[int] | None = None def filter_exclude_ids(self, value: set[int] | None): diff --git a/club/static/bundled/club/components/ajax-select-index.ts b/club/static/bundled/club/components/ajax-select-index.ts index d58de56c..56c93760 100644 --- a/club/static/bundled/club/components/ajax-select-index.ts +++ b/club/static/bundled/club/components/ajax-select-index.ts @@ -1,7 +1,7 @@ -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") diff --git a/club/tests/test_membership.py b/club/tests/test_membership.py index 2420043d..e24fe9d0 100644 --- a/club/tests/test_membership.py +++ b/club/tests/test_membership.py @@ -72,25 +72,6 @@ class TestMembershipQuerySet(TestClub): expected.sort(key=lambda i: i.id) assert members == expected - def test_update_invalidate_cache(self): - """Test that the `update` queryset method properly invalidate cache.""" - mem_skia = self.simple_board_member.memberships.get(club=self.club) - cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia) - self.simple_board_member.memberships.update(end_date=localtime(now()).date()) - assert ( - cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}") - == "not_member" - ) - - mem_richard = self.richard.memberships.get(club=self.club) - cache.set( - f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard - ) - self.richard.memberships.update(role=5) - new_mem = self.richard.memberships.get(club=self.club) - assert new_mem != "not_member" - assert new_mem.role == 5 - def test_update_change_club_groups(self): """Test that `update` set the user groups accordingly.""" user = baker.make(User) @@ -112,24 +93,6 @@ class TestMembershipQuerySet(TestClub): assert not user.groups.contains(members_group) assert not user.groups.contains(board_group) - def test_delete_invalidate_cache(self): - """Test that the `delete` queryset properly invalidate cache.""" - mem_skia = self.simple_board_member.memberships.get(club=self.club) - mem_comptable = self.president.memberships.get(club=self.club) - cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia) - cache.set( - f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable - ) - - # should delete the subscriptions of simple_board_member and president - self.club.members.ongoing().board().delete() - - for membership in (mem_skia, mem_comptable): - cached_mem = cache.get( - f"membership_{membership.club_id}_{membership.user_id}" - ) - assert cached_mem == "not_member" - def test_delete_remove_from_groups(self): """Test that `delete` removes from club groups""" user = baker.make(User) diff --git a/com/ics_calendar.py b/com/ics_calendar.py index b0a2da5b..d502f8fe 100644 --- a/com/ics_calendar.py +++ b/com/ics_calendar.py @@ -4,15 +4,16 @@ 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 F, QuerySet +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 from ical.calendar_stream import IcsCalendarStream from ical.event import Event +from ical.types import Frequency, Recur -from com.models import NewsDate +from com.models import News, NewsDate from core.models import User @@ -42,9 +43,9 @@ class IcsCalendar: with open(cls._INTERNAL_CALENDAR, "wb") as f: _ = f.write( cls.ics_from_queryset( - NewsDate.objects.filter( - news__is_published=True, - end_date__gte=timezone.now() - (relativedelta(months=6)), + News.objects.filter( + is_published=True, + dates__end_date__gte=timezone.now() - relativedelta(months=6), ) ) ) @@ -53,24 +54,35 @@ class IcsCalendar: @classmethod def get_unpublished(cls, user: User) -> bytes: return cls.ics_from_queryset( - NewsDate.objects.viewable_by(user).filter( - news__is_published=False, - end_date__gte=timezone.now() - (relativedelta(months=6)), - ), + News.objects.viewable_by(user).filter( + is_published=False, + dates__end_date__gte=timezone.now() - relativedelta(months=6), + ) ) @classmethod - def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes: + def ics_from_queryset(cls, queryset: QuerySet[News]) -> bytes: calendar = Calendar() - for news_date in queryset.annotate(news_title=F("news__title")): + date_subquery = NewsDate.objects.filter(news=OuterRef("pk")).order_by( + "start_date" + ) + queryset = queryset.annotate( + start=Subquery(date_subquery.values("start_date")[:1]), + end=Subquery(date_subquery.values("end_date")[:1]), + nb_dates=Count("dates"), + ) + for news in queryset: event = Event( - summary=news_date.news_title, - start=news_date.start_date, - end=news_date.end_date, + summary=news.title, + description=news.summary, + dtstart=news.start, + dtend=news.end, url=as_absolute_url( - reverse("com:news_detail", kwargs={"news_id": news_date.news_id}) + reverse("com:news_detail", kwargs={"news_id": news.id}) ), ) + if news.nb_dates > 1: + event.rrule = Recur(freq=Frequency.WEEKLY, count=news.nb_dates) calendar.events.append(event) return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8") diff --git a/com/schemas.py b/com/schemas.py index 3933daa1..efc01f01 100644 --- a/com/schemas.py +++ b/com/schemas.py @@ -1,9 +1,9 @@ from datetime import datetime +from typing import Annotated -from ninja import FilterSchema, ModelSchema +from ninja import FilterLookup, FilterSchema, ModelSchema from ninja_extra import service_resolver from ninja_extra.context import RouteContext -from pydantic import Field from club.schemas import ClubProfileSchema from com.models import News, NewsDate @@ -11,12 +11,12 @@ from core.markdown import markdown class NewsDateFilterSchema(FilterSchema): - before: datetime | None = Field(None, q="end_date__lt") - after: datetime | None = Field(None, q="start_date__gt") - club_id: int | None = Field(None, q="news__club_id") + before: Annotated[datetime | None, FilterLookup("end_date__lt")] = None + after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None + club_id: Annotated[int | None, FilterLookup("news__club_id")] = None news_id: int | None = None - is_published: bool | None = Field(None, q="news__is_published") - title: str | None = Field(None, q="news__title__icontains") + is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None + title: Annotated[str | None, FilterLookup("news__title__icontains")] = None class NewsSchema(ModelSchema): diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index bc2ec9b4..8eb159c1 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -1,6 +1,4 @@ -import { makeUrl } from "#core:utils/api"; -import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; -import { Calendar, type EventClickArg } 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"; @@ -8,6 +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 { makeUrl } from "#core:utils/api.ts"; +import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { calendarCalendarInternal, calendarCalendarUnpublished, @@ -25,6 +25,11 @@ export class IcsCalendar extends inheritHtmlElement("div") { private canDelete = false; private helpUrl = ""; + // Hack variable to detect recurring events + // The underlying ics library doesn't include any info about rrules + // That's why we have to detect those events ourselves + private recurrenceMap: Map = new Map(); + attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { if (name === "locale") { this.locale = newValue; @@ -90,11 +95,13 @@ export class IcsCalendar extends inheritHtmlElement("div") { .split("/") .filter((s) => s) // Remove blank characters .pop(), + 10, ); } refreshEvents() { this.click(); // Remove focus from popup + this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event this.calendar.refetchEvents(); } @@ -153,12 +160,24 @@ export class IcsCalendar extends inheritHtmlElement("div") { } 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 + // for recurring events and always different for single events + const firstEvent = this.recurrenceMap.get(eventData.url); + if (firstEvent !== undefined) { + eventData.extendedProps.isRecurring = true; + firstEvent.extendedProps.isRecurring = true; // Don't forget the first event + } + this.recurrenceMap.set(eventData.url, eventData); + }; return [ { url: `${await makeUrl(calendarCalendarInternal)}`, format: "ics", className: "internal", cache: false, + eventDataTransform: tagRecurringEvents, }, { url: `${await makeUrl(calendarCalendarUnpublished)}`, @@ -166,6 +185,7 @@ export class IcsCalendar extends inheritHtmlElement("div") { color: "red", className: "unpublished", cache: false, + eventDataTransform: tagRecurringEvents, }, ]; } @@ -361,6 +381,14 @@ export class IcsCalendar extends inheritHtmlElement("div") { event.jsEvent.preventDefault(); this.createEventDetailPopup(event); }, + eventClassNames: (classNamesEvent: EventContentArg) => { + const classes: string[] = []; + if (classNamesEvent.event.extendedProps?.isRecurring) { + classes.push("recurring"); + } + + return classes; + }, }); this.calendar.render(); diff --git a/com/static/bundled/com/moderation-alert-index.ts b/com/static/bundled/com/moderation-alert-index.ts index f2ff1806..70dc871c 100644 --- a/com/static/bundled/com/moderation-alert-index.ts +++ b/com/static/bundled/com/moderation-alert-index.ts @@ -1,4 +1,4 @@ -import { exportToHtml } from "#core:utils/globals"; +import { exportToHtml } from "#core:utils/globals.ts"; import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi"; // This will be used in jinja templates, diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index 1c0a15bd..74a76397 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -18,6 +18,8 @@ --event-details-border-radius: 4px; --event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%); --event-details-max-width: 600px; + --event-recurring-internal-color: #6f69cd; + --event-recurring-unpublished-color: orange; } ics-calendar { @@ -146,4 +148,29 @@ ics-calendar { .tooltip.calendar-copy-tooltip.text-copied { opacity: 0; transition: opacity 500ms ease-out; -} \ No newline at end of file +} + +// We have to override the color set by the lib in the html +// Hence the !important tag everywhere +.internal.recurring { + .fc-daygrid-event-dot { + border-color: var(--event-recurring-internal-color) !important; + } + + &.fc-daygrid-block-event { + background-color: var(--event-recurring-internal-color) !important; + border-color: var(--event-recurring-internal-color) !important; + } + +} + +.unpublished.recurring { + .fc-daygrid-event-dot { + border-color: var(--event-recurring-unpublished-color) !important; + } + + &.fc-daygrid-block-event { + background-color: var(--event-recurring-unpublished-color) !important; + border-color: var(--event-recurring-unpublished-color) !important; + } +} diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 9a462607..2f6dc26e 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -203,7 +203,7 @@ @@ -46,7 +55,16 @@ {{ u.get_mini_item()|safe }} - {{ delete_godfather(user, profile, u, False) }} + {% if user == profile or user.is_root or user.is_board_member %} +
+ {% csrf_token %} + +
+ {% endif %} {% endfor %} diff --git a/core/templates/core/user_stats.jinja b/core/templates/core/user_stats.jinja index 9b900117..621c4956 100644 --- a/core/templates/core/user_stats.jinja +++ b/core/templates/core/user_stats.jinja @@ -11,32 +11,35 @@ {% block content %}
- {% if profile.permanencies %} + {% if total_perm_time %}

{% trans %}Permanencies{% endtrans %}

-
Foyer :{{ total_foyer_time }}
-
Gommette :{{ total_gommette_time }}
-
MDE :{{ total_mde_time }}
-
Total :{{ total_perm_time }}
+ {% for perm in perm_time %} +
+ {{ perm["counter__name"] }} : + {{ perm["total"]|format_timedelta }} +
+ {% endfor %} +
Total :{{ total_perm_time|format_timedelta }}
{% endif %} -

{% trans %}Buyings{% endtrans %}

-
Foyer :{{ total_foyer_buyings }} €
-
Gommette :{{ total_gommette_buyings }} €
-
MDE :{{ total_mde_buyings }} €
-
Total :{{ total_foyer_buyings + total_gommette_buyings + total_mde_buyings }} € -
+ {% for sum in purchase_sums %} +
+ {{ sum["counter__name"] }} + {{ sum["total"] }} € +
+ {% endfor %} +
Total : {{ total_purchases }} €
-
-

{% trans %}Product top 10{% endtrans %}

+

{% trans %}Product top 15{% endtrans %}

diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja index e3d09748..a9fb76b6 100644 --- a/core/templates/core/user_tools.jinja +++ b/core/templates/core/user_tools.jinja @@ -184,18 +184,18 @@ {% endif %} - {% if user.has_perm("pedagogy.add_uv") or user.has_perm("pedagogy.delete_uvcomment") %} + {% if user.has_perm("pedagogy.add_ue") or user.has_perm("pedagogy.delete_uecomment") %}

{% trans %}Pedagogy{% endtrans %}