34 Commits

Author SHA1 Message Date
dependabot[bot]
30c327a6f1 Bump virtualenv from 20.35.4 to 20.36.1
Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.35.4 to 20.36.1.
- [Release notes](https://github.com/pypa/virtualenv/releases)
- [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/virtualenv/compare/20.35.4...20.36.1)

---
updated-dependencies:
- dependency-name: virtualenv
  dependency-version: 20.36.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 14:26:12 +00:00
thomas girod
2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
thomas girod
4391f63de8 Merge pull request #1292 from ae-utbm/merge-back
Merge back
2026-02-13 15:19:35 +01:00
Sli
8b7eb6edf9 Merge branch 'master' into taiste 2026-02-13 15:16:26 +01:00
f8cda3a31d Merge pull request #1291 from ae-utbm/update-hey-api
Update hey-api
2026-02-13 14:32:18 +01:00
Sli
433d29fcdb Update hey-api 2026-02-13 14:27:52 +01:00
thomas girod
514b8bbec7 Merge pull request #1290 from ae-utbm/update-deps
Update deps
2026-02-13 14:21:15 +01:00
imperosol
84033f37cf update BiomeJS 2026-02-13 14:09:27 +01:00
imperosol
e71f76ea91 remove shorten.min.js 2026-02-13 13:13:40 +01:00
imperosol
530475c4ee update JS dependencies 2026-02-13 11:58:20 +01:00
imperosol
e992bebd68 update python dependencies 2026-02-13 11:52:02 +01:00
thomas girod
8f1c786aa2 Merge pull request #1274 from ae-utbm/remove-club-cache
remove cache calls to fetch user membership
2026-02-11 12:47:06 +01:00
imperosol
c5ae81aae7 update docstrings 2026-02-10 13:08:36 +01:00
imperosol
252acc64c1 remove cache calls to fetch user membership 2026-02-10 13:08:36 +01:00
thomas girod
0d2430a5d4 Merge pull request #1288 from ae-utbm/cb-message
explanation message when eboutic bank payments are disabled
2026-02-09 19:37:12 +01:00
imperosol
b6f77dea97 apply review suggestion 2026-02-09 15:24:52 +01:00
imperosol
df2e65a991 explanation message when eboutic bank payments are disabled 2026-02-08 16:21:09 +01:00
thomas girod
de776045a8 Merge pull request #1287 from ae-utbm/ruff
Update Ruff
2026-02-04 03:10:33 +01:00
imperosol
367ea703ce remove fmt: off 2026-02-03 21:23:34 +01:00
imperosol
bdcb802da8 apply ruff rule PLW0108 2026-02-03 21:12:14 +01:00
imperosol
4e4b5a39f7 update ruff 2026-02-03 21:11:13 +01:00
51534629ed Merge pull request #1279 from ae-utbm/fix_elections
fix: bad value for blank vote and better flow for invalid form
2026-02-03 15:18:33 +01:00
Sli
c042c8e8a3 fix: bad value for blank vote and better flow for invalid form
* Add an error message when looking at a public election without being logged in
* Add correct value for blank vote on single vote field
* Redirect to view with an error message if an invalid form has been submitted
2026-02-03 10:05:36 +01:00
thomas girod
5af894060a Merge pull request #1273 from ae-utbm/fix-counter
fix: wrong quantity displayed on click after removing item
2026-01-21 22:42:27 +01:00
679b8dac1c Merge pull request #1278 from ae-utbm/download-picture-fix
Fix image file generation on user image download
2026-01-21 22:04:09 +01:00
Sli
e9eb3dc17d Fix image file generation on user image download
* Add image id on the name to avoid error with images with the exact same date (if we have epoch for example)
* Fix album name due to schema change not reflected here
2026-01-07 17:49:14 +01:00
imperosol
53a3dc0060 fix: wrong quantity displayed on click after removing item 2025-12-20 06:47:29 +01:00
Titouan
12b098feac Merge pull request #1269 from ae-utbm/dependence_update
update uv dependencies
2025-12-16 10:52:35 +01:00
TitouanDor
0fb86e5d77 modification pyproject.toml 2025-12-15 16:21:22 +01:00
TitouanDor
523e0ff0ee update uv dependencies 2025-12-15 15:22:14 +01:00
thomas girod
0a4d21611e Merge pull request #1255 from ae-utbm/taiste
Refactors, better `PageRev` handling, better user invisibilisation and fixes
2025-11-19 14:02:59 +01:00
thomas girod
992b6d6b79 Merge pull request #1238 from ae-utbm/taiste
Sith theme, `Selling.date` index, galaxy simplification, OG tags, dependencies update, bugfixes and others
2025-11-10 13:19:43 +01:00
Kenneth Soares
710b4aa942 Merge pull request #1213 from ae-utbm/taiste
HTMX, Alpine, Invoice Calls, Products, Bugfixes, Other
2025-10-18 17:29:15 +02:00
Kenneth Soares
5fee2e4720 Merge pull request #1180 from ae-utbm/taiste
Com, Subscriptions, Posters, Others
2025-09-19 21:31:28 +02:00
61 changed files with 1495 additions and 2098 deletions

View File

@@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.14.4 rev: v0.15.0
hooks: hooks:
- id: ruff-check # just check the code, and print the errors - id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing - id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1 rev: v0.6.1
hooks: hooks:
- id: biome-check - id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"] additional_dependencies: ["@biomejs/biome@2.3.14"]
- repo: https://github.com/rtts/djhtml - repo: https://github.com/rtts/djhtml
rev: 3.0.10 rev: 3.0.10
hooks: hooks:

View File

@@ -7,20 +7,34 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"ignore": ["*.min.*", "staticfiles/generated"] "includes": ["**/static/**"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space",
"lineWidth": 88 "lineWidth": 88
}, },
"organizeImports": {
"enabled": true
},
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "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": { "javascript": {

View File

@@ -26,7 +26,6 @@ from __future__ import annotations
from typing import Iterable, Self from typing import Iterable, Self
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction from django.db import models, transaction
@@ -187,9 +186,6 @@ class Club(models.Model):
self.page.save(force_lock=True) self.page.save(force_lock=True)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: 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.board_group.delete()
self.members_group.delete() self.members_group.delete()
return super().delete(*args, **kwargs) 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.""" """Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user) return self.has_rights_in_club(user)
def get_membership_for(self, user: User) -> Membership | None: @cached_property
"""Return the current membership the given user. def current_members(self) -> list[Membership]:
return list(self.members.ongoing().select_related("user").order_by("-role"))
Note: def get_membership_for(self, user: User) -> Membership | None:
The result is cached. """Return the current membership of the given user."""
"""
if user.is_anonymous: if user.is_anonymous:
return None return None
membership = cache.get(f"membership_{self.id}_{user.id}") return next((m for m in self.current_members if m.user_id == user.id), None)
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
def has_rights_in_club(self, user: User) -> bool: def has_rights_in_club(self, user: User) -> bool:
return user.is_in_group(pk=self.board_group_id) 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. are included, even if there are no more members.
If you want to get the users who are currently in the board, 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) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
@@ -288,42 +275,29 @@ class MembershipQuerySet(models.QuerySet):
) )
def update(self, **kwargs) -> int: def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership. """Remove users from club groups they are no more in
Update the cache, when necessary, remove
users from club groups they are no more in
and add them in the club groups they should be in. and add them in the club groups they should be in.
Be aware that this adds three db queries : Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform - one to retrieve the updated memberships
group attribution. - one to perform group removal
- and one to perform group attribution.
""" """
nb_rows = super().update(**kwargs) nb_rows = super().update(**kwargs)
if nb_rows == 0: 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 return 0
cache_memberships = {}
memberships = set(self.select_related("club")) memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones # 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._remove_club_groups(memberships)
Membership._add_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 return nb_rows
def delete(self) -> tuple[int, dict[str, int]]: def delete(self) -> tuple[int, dict[str, int]]:
"""Work just like the default Django's delete() method, """Work just like the default Django's delete() method,
but add a cache invalidation for the elements of the queryset but also remove the concerned users from the club groups.
before the deletion,
and a removal of the user from the club groups.
Be aware that this adds some db queries : Be aware that this adds some db queries :
@@ -339,12 +313,6 @@ class MembershipQuerySet(models.QuerySet):
nb_rows, rows_counts = super().delete() nb_rows, rows_counts = super().delete()
if nb_rows > 0: if nb_rows > 0:
Membership._remove_club_groups(memberships) 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 return nb_rows, rows_counts
@@ -408,9 +376,6 @@ class Membership(models.Model):
self._remove_club_groups([self]) self._remove_club_groups([self])
if self.end_date is None: if self.end_date is None:
self._add_club_groups([self]) 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): def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id}) return reverse("club:club_members", kwargs={"club_id": self.club_id})
@@ -431,7 +396,6 @@ class Membership(models.Model):
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self._remove_club_groups([self]) self._remove_club_groups([self])
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod @staticmethod
def _remove_club_groups( def _remove_club_groups(

View File

@@ -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 { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; 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"; import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select") @registerComponent("club-ajax-select")

View File

@@ -72,25 +72,6 @@ class TestMembershipQuerySet(TestClub):
expected.sort(key=lambda i: i.id) expected.sort(key=lambda i: i.id)
assert members == expected 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): def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly.""" """Test that `update` set the user groups accordingly."""
user = baker.make(User) user = baker.make(User)
@@ -112,24 +93,6 @@ class TestMembershipQuerySet(TestClub):
assert not user.groups.contains(members_group) assert not user.groups.contains(members_group)
assert not user.groups.contains(board_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): def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups""" """Test that `delete` removes from club groups"""
user = baker.make(User) user = baker.make(User)

View File

@@ -1,5 +1,3 @@
import { makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core"; import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal"; import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb"; import enLocale from "@fullcalendar/core/locales/en-gb";
@@ -8,6 +6,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar"; import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list"; import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html"; 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 { import {
calendarCalendarInternal, calendarCalendarInternal,
calendarCalendarUnpublished, calendarCalendarUnpublished,
@@ -95,6 +95,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.split("/") .split("/")
.filter((s) => s) // Remove blank characters .filter((s) => s) // Remove blank characters
.pop(), .pop(),
10,
); );
} }

View File

@@ -1,4 +1,4 @@
import { exportToHtml } from "#core:utils/globals"; import { exportToHtml } from "#core:utils/globals.ts";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi"; import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates, // This will be used in jinja templates,

View File

@@ -1,7 +1,7 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { limitedChoices } from "#core:alpine/limited-choices.ts";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications.ts";
Alpine.plugin([sort, limitedChoices]); Alpine.plugin([sort, limitedChoices]);
Alpine.magic("notifications", notificationPlugin); Alpine.magic("notifications", notificationPlugin);

View File

@@ -56,7 +56,7 @@ export function limitedChoices(Alpine: AlpineType) {
effect(() => { effect(() => {
getMaxChoices((value: string) => { getMaxChoices((value: string) => {
const previousValue = maxChoices; const previousValue = maxChoices;
maxChoices = Number.parseInt(value); maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) { if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered. // The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed // Some currently selected elements may need to be removed

View File

@@ -1,4 +1,3 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select"; import TomSelect from "tom-select";
import type { import type {
RecursivePartial, RecursivePartial,
@@ -7,6 +6,7 @@ import type {
TomSettings, TomSettings,
} from "tom-select/dist/types/types"; } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") { export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [ static observedAttributes = [
@@ -29,7 +29,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
) { ) {
switch (name) { switch (name) {
case "delay": { case "delay": {
this.delay = Number.parseInt(newValue) ?? null; this.delay = Number.parseInt(newValue, 10) ?? null;
break; break;
} }
case "placeholder": { case "placeholder": {
@@ -37,11 +37,11 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
break; break;
} }
case "max": { case "max": {
this.max = Number.parseInt(newValue) ?? null; this.max = Number.parseInt(newValue, 10) ?? null;
break; break;
} }
case "min-characters-for-search": { case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0; this.minCharNumberForSearch = Number.parseInt(newValue, 10) ?? 0;
break; break;
} }
default: { default: {

View File

@@ -1,20 +1,19 @@
import "tom-select/dist/css/tom-select.default.css"; import "tom-select/dist/css/tom-select.default.css";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import {
type GroupSchema,
type SithFileSchema,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import { import {
AjaxSelect, AjaxSelect,
AutoCompleteSelectBase, AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base"; } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import {
type GroupSchema,
groupSearchGroup,
type SithFileSchema,
sithfileSearchFiles,
type UserProfileSchema,
userSearchUsers,
} from "#openapi";
@registerComponent("autocomplete-select") @registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {} export class AutoCompleteSelect extends AutoCompleteSelectBase {}

View File

@@ -1,14 +1,14 @@
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde // biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
import "easymde/src/css/easymde.css"; import "easymde/src/css/easymde.css";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE // biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror"; import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import { import {
type UploadUploadImageErrors,
markdownRenderMarkdown, markdownRenderMarkdown,
type UploadUploadImageErrors,
uploadUploadImage, uploadUploadImage,
} from "#openapi"; } from "#openapi";

View File

@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
/** /**
* Web component used to import css files only once * Web component used to import css files only once

View File

@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
@registerComponent("nfc-input") @registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") { export class NfcInput extends inheritHtmlElement("input") {

View File

@@ -1,6 +1,6 @@
import { registerComponent } from "#core:utils/web-components";
import { html, render } from "lit-html"; import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components.ts";
@registerComponent("ui-tab") @registerComponent("ui-tab")
export class Tab extends HTMLElement { export class Tab extends HTMLElement {

View File

@@ -1,4 +1,4 @@
import { exportToHtml } from "#core:utils/globals"; import { exportToHtml } from "#core:utils/globals.ts";
exportToHtml("showMenu", () => { exportToHtml("showMenu", () => {
const navbar = document.getElementById("navbar-content"); const navbar = document.getElementById("navbar-content");

View File

@@ -26,7 +26,7 @@ function showMore(element: HTMLElement) {
const fullContent = element.innerHTML; const fullContent = element.innerHTML;
const clippedContent = clip( const clippedContent = clip(
element.innerHTML, element.innerHTML,
Number.parseInt(element.getAttribute("show-more") as string), Number.parseInt(element.getAttribute("show-more") as string, 10),
{ {
html: true, html: true,
}, },

View File

@@ -1,9 +1,9 @@
import { import {
type Placement,
autoPlacement, autoPlacement,
computePosition, computePosition,
flip, flip,
offset, offset,
type Placement,
size, size,
} from "@floating-ui/dom"; } from "@floating-ui/dom";

View File

@@ -1,6 +1,6 @@
import { exportToHtml } from "#core:utils/globals"; // biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation
// biome-ignore lint/style/noNamespaceImport: this is the recommended way from the documentation
import * as Sentry from "@sentry/browser"; import * as Sentry from "@sentry/browser";
import { exportToHtml } from "#core:utils/globals.ts";
interface LoggedUser { interface LoggedUser {
name: string; name: string;

View File

@@ -8,7 +8,6 @@
// This has been modified to not trigger biome linting // This has been modified to not trigger biome linting
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window { interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name // biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFMessage: NDEFMessage; NDEFMessage: NDEFMessage;
@@ -28,7 +27,6 @@ declare interface NDEFMessageInit {
// biome-ignore lint/style/useNamingConvention: this is the official API name // biome-ignore lint/style/useNamingConvention: this is the official API name
declare type NDEFRecordDataSource = string | BufferSource | NDEFMessageInit; declare type NDEFRecordDataSource = string | BufferSource | NDEFMessageInit;
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window { interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name // biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFRecord: NDEFRecord; NDEFRecord: NDEFRecord;
@@ -74,7 +72,6 @@ declare class NDEFReader extends EventTarget {
makeReadOnly: (options?: NDEFMakeReadOnlyOptions) => Promise<void>; makeReadOnly: (options?: NDEFMakeReadOnlyOptions) => Promise<void>;
} }
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window { interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name // biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFReadingEvent: NDEFReadingEvent; NDEFReadingEvent: NDEFReadingEvent;

View File

@@ -1,4 +1,3 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, { import cytoscape, {
type ElementDefinition, type ElementDefinition,
type NodeSingular, type NodeSingular,
@@ -6,7 +5,8 @@ import cytoscape, {
} from "cytoscape"; } from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu"; import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay"; import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay); cytoscape.use(klay);
cytoscape.use(cxtmenu); cytoscape.use(cxtmenu);
@@ -200,7 +200,7 @@ document.addEventListener("alpine:init", () => {
isZoomEnabled: !isMobile(), isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) { getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop)); const value = Number.parseInt(initialUrlParams.get(prop), 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth; return defaultDepth;
} }

View File

@@ -1,5 +1,5 @@
import { client, type Options } from "#openapi";
import type { Client, RequestResult, TDataShape } from "#openapi:client"; import type { Client, RequestResult, TDataShape } from "#openapi:client";
import { type Options, client } from "#openapi";
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
count: number; count: number;

View File

@@ -1,4 +1,4 @@
import type { NestedKeyOf } from "#core:utils/types"; import type { NestedKeyOf } from "#core:utils/types.ts";
interface StringifyOptions<T extends object> { interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */ /** The columns to include in the resulting CSV. */

View File

@@ -10,7 +10,6 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
window.customElements.define(name, component, options); window.customElements.define(name, component, options);
} catch (e) { } catch (e) {
if (e instanceof DOMException) { if (e instanceof DOMException) {
// biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
console.warn(e.message); console.warn(e.message);
return; return;
} }

View File

@@ -1,124 +0,0 @@
// Copyright 2013 Viral Patel and other contributors
// http://viralpatel.net
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
!(function (e) {
e.fn.shorten = function (s) {
"use strict";
var t = {
showChars: 100,
minHideChars: 10,
ellipsesText: "...",
moreText: "more",
lessText: "less",
onLess: function () {},
onMore: function () {},
errMsg: null,
force: !1,
};
return (
s && e.extend(t, s),
(!e(this).data("jquery.shorten") || !!t.force) &&
(e(this).data("jquery.shorten", !0),
e(document).off("click", ".morelink"),
e(document).on(
{
click: function () {
var s = e(this);
return (
s.hasClass("less")
? (s.removeClass("less"),
s.html(t.moreText),
s
.parent()
.prev()
.animate({}, function () {
s.parent().prev().prev().show();
})
.hide("fast", function () {
t.onLess();
}))
: (s.addClass("less"),
s.html(t.lessText),
s
.parent()
.prev()
.animate({}, function () {
s.parent().prev().prev().hide();
})
.show("fast", function () {
t.onMore();
})),
!1
);
},
},
".morelink",
),
this.each(function () {
var s = e(this),
n = s.html();
if (s.text().length > t.showChars + t.minHideChars) {
var r = n.substr(0, t.showChars);
if (r.indexOf("<") >= 0) {
for (
var a = !1, o = "", i = 0, l = [], h = null, c = 0, f = 0;
f <= t.showChars;
c++
)
if (
("<" != n[c] ||
a ||
((a = !0),
"/" == (h = n.substring(c + 1, n.indexOf(">", c)))[0]
? h != "/" + l[0]
? (t.errMsg =
"ERROR en HTML: the top of the stack should be the tag that closes")
: l.shift()
: "br" != h.toLowerCase() && l.unshift(h)),
a && ">" == n[c] && (a = !1),
a)
)
o += n.charAt(c);
else if ((f++, i <= t.showChars)) (o += n.charAt(c)), i++;
else if (l.length > 0) {
for (j = 0; j < l.length; j++) o += "</" + l[j] + ">";
break;
}
r = e("<div/>")
.html(o + '<span class="ellip">' + t.ellipsesText + "</span>")
.html();
} else r += t.ellipsesText;
var p =
'<div class="shortcontent">' +
r +
'</div><div class="allcontent">' +
n +
'</div><span><a href="javascript://nop/" class="morelink">' +
t.moreText +
"</a></span>";
s.html(p),
s.find(".allcontent").hide(),
e(".shortcontent p:last", s).css("margin-bottom", 0);
}
}))
);
};
})(jQuery);

View File

@@ -22,19 +22,17 @@ from bs4 import BeautifulSoup
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core import mail from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View from django.views.generic import View
from django.views.generic.base import ContextMixin from django.views.generic.base import ContextMixin
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Club, Membership from club.models import Club
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo from core.models import AnonymousUser, Group, Page, User, validate_promo
@@ -436,23 +434,6 @@ class TestUserIsInGroup(TestCase):
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)
def test_cache_properly_cleared_membership(self):
"""Test that when the membership of a user end,
the cache is properly invalidated.
"""
membership = baker.make(Membership, club=self.club, user=self.public_user)
cache.clear()
self.club.get_membership_for(self.public_user) # this should populate the cache
assert membership == cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
membership.end_date = now() - timedelta(minutes=5)
membership.save()
cached_membership = cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
assert cached_membership == "not_member"
def test_not_existing_group(self): def test_not_existing_group(self):
"""Test that searching for a not existing group """Test that searching for a not existing group
returns False. returns False.

View File

@@ -118,9 +118,9 @@ class TestFileModerationView:
(lambda: None, 403), # Anonymous user (lambda: None, 403), # Anonymous user
(lambda: baker.make(User, is_superuser=True), 200), (lambda: baker.make(User, is_superuser=True), 200),
(lambda: baker.make(User), 403), (lambda: baker.make(User), 403),
(lambda: subscriber_user.make(), 403), (subscriber_user.make, 403),
(lambda: old_subscriber_user.make(), 403), (old_subscriber_user.make, 403),
(lambda: board_user.make(), 403), (board_user.make, 403),
], ],
) )
def test_view_access( def test_view_access(
@@ -262,7 +262,7 @@ def test_apply_rights_recursively():
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize( @pytest.mark.parametrize(
("user_receipe", "file", "expected_status"), ("user_recipe", "file", "expected_status"),
[ [
( (
lambda: None, lambda: None,
@@ -279,21 +279,21 @@ def test_apply_rights_recursively():
403, 403,
), ),
( (
lambda: subscriber_user.make(), subscriber_user.make,
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
), ),
200, 200,
), ),
( (
lambda: old_subscriber_user.make(), old_subscriber_user.make,
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
), ),
200, 200,
), ),
( (
lambda: old_subscriber_user.make(), old_subscriber_user.make,
SimpleUploadedFile( SimpleUploadedFile(
"ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg", "ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg",
content=RED_PIXEL_PNG, content=RED_PIXEL_PNG,
@@ -302,21 +302,21 @@ def test_apply_rights_recursively():
200, 200,
), # very long file name ), # very long file name
( (
lambda: old_subscriber_user.make(), old_subscriber_user.make,
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=b"invalid", content_type="image/jpg" "test.jpg", content=b"invalid", content_type="image/jpg"
), ),
422, 422,
), ),
( (
lambda: old_subscriber_user.make(), old_subscriber_user.make,
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid" "test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
), ),
200, # PIL can guess 200, # PIL can guess
), ),
( (
lambda: old_subscriber_user.make(), old_subscriber_user.make,
SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"), SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"),
422, 422,
), ),
@@ -324,11 +324,11 @@ def test_apply_rights_recursively():
) )
def test_quick_upload_image( def test_quick_upload_image(
client: Client, client: Client,
user_receipe: Callable[[], User | None], user_recipe: Callable[[], User | None],
file: UploadedFile | None, file: UploadedFile | None,
expected_status: int, expected_status: int,
): ):
if (user := user_receipe()) is not None: if (user := user_recipe()) is not None:
client.force_login(user) client.force_login(user)
resp = client.post( resp = client.post(
reverse("api:quick_upload_image"), {"file": file} if file is not None else {} reverse("api:quick_upload_image"), {"file": file} if file is not None else {}

View File

@@ -418,9 +418,7 @@ class TestUserQuerySetViewableBy:
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]} assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize( @pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser])
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
def test_not_subscriber(self, users: list[User], user_factory): def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory() user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)

View File

@@ -65,10 +65,10 @@ class Command(BaseCommand):
"""Fetch the users which have a pending account dump.""" """Fetch the users which have a pending account dump."""
threshold = now() - settings.SITH_ACCOUNT_DUMP_DELTA threshold = now() - settings.SITH_ACCOUNT_DUMP_DELTA
ongoing_dump_operations: QuerySet[AccountDump] = ( ongoing_dump_operations: QuerySet[AccountDump] = (
AccountDump.objects.ongoing() AccountDump.objects.ongoing().filter(
.filter(customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold) customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold
) # fmt: off )
# cf. https://github.com/astral-sh/ruff/issues/14103 )
return ( return (
User.objects.filter(Exists(ongoing_dump_operations)) User.objects.filter(Exists(ongoing_dump_operations))
.annotate( .annotate(

View File

@@ -447,8 +447,7 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all()) buying_groups = list(self.buying_groups.all())
if not buying_groups: if not buying_groups:
return True return True
res = any(user.is_in_group(pk=group.id) for group in buying_groups) return any(user.is_in_group(pk=group.id) for group in buying_groups)
return res
@property @property
def profit(self): def profit(self):

View File

@@ -1,4 +1,4 @@
import type { Product } from "#counter:counter/types"; import type { Product } from "#counter:counter/types.ts";
export class BasketItem { export class BasketItem {
quantity: number; quantity: number;

View File

@@ -1,14 +1,14 @@
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 { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; 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 { import {
type CounterSchema, type CounterSchema,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter, counterSearchCounter,
type ProductTypeSchema,
productSearchProducts, productSearchProducts,
producttypeFetchAll, producttypeFetchAll,
type SimpleProductSchema,
} from "#openapi"; } from "#openapi";
@registerComponent("product-ajax-select") @registerComponent("product-ajax-select")

View File

@@ -1,13 +1,13 @@
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types"; import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
const productParsingRegex = /^(\d+x)?(.*)/i; const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] { function parseProduct(query: string): [number, string] {
const parsed = productParsingRegex.exec(query); const parsed = productParsingRegex.exec(query);
return [Number.parseInt(parsed[1] || "1"), parsed[2]]; return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
} }
@registerComponent("counter-product-select") @registerComponent("counter-product-select")
@@ -80,9 +80,9 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
// We need to manually set weights or it results on an inconsistent // We need to manually set weights or it results on an inconsistent
// behavior between production and development environment // behavior between production and development environment
searchField: [ searchField: [
// @ts-ignore documentation says it's fine, specified type is wrong // @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
// @ts-ignore documentation says it's fine, specified type is wrong // @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 }, { field: "text", weight: 0.5 },
], ],
}; };

View File

@@ -1,6 +1,6 @@
import { AlertMessage } from "#core:utils/alert-message"; import { AlertMessage } from "#core:utils/alert-message.ts";
import { BasketItem } from "#counter:counter/basket"; import { BasketItem } from "#counter:counter/basket.ts";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {

View File

@@ -1,9 +1,13 @@
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import { showSaveFilePicker } from "native-file-system-adapter"; import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import { paginated } from "#core:utils/api.ts";
import { csv } from "#core:utils/csv.ts";
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import type { NestedKeyOf } from "#core:utils/types.ts";
import { import {
type ProductSchema, type ProductSchema,
type ProductSearchProductsDetailedData, type ProductSearchProductsDetailedData,

View File

@@ -1,5 +1,5 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { AlertMessage } from "#core:utils/alert-message.ts";
import { producttypeReorder } from "#openapi"; import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
@@ -22,7 +22,7 @@ document.addEventListener("alpine:init", () => {
const productTypes = this.$refs.productTypes const productTypes = this.$refs.productTypes
.childNodes as NodeListOf<HTMLLIElement>; .childNodes as NodeListOf<HTMLLIElement>;
const getId = (elem: HTMLLIElement) => const getId = (elem: HTMLLIElement) =>
Number.parseInt(elem.getAttribute("x-sort:item")); Number.parseInt(elem.getAttribute("x-sort:item"), 10);
const query = const query =
newPosition === 0 newPosition === 0
? { above: getId(productTypes.item(1)) } ? { above: getId(productTypes.item(1)) }

View File

@@ -104,7 +104,7 @@
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li> <li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)"> <template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
<li> <li>
<template x-for="error in item.errors"> <template x-for="error in item.errors">
<div class="alert alert-red" x-text="error"> <div class="alert alert-red" x-text="error">

View File

@@ -43,12 +43,13 @@ def get_eboutic_products(user: User) -> list[Product]:
products = ( products = (
get_eboutic() get_eboutic()
.products.filter(product_type__isnull=False) .products.filter(product_type__isnull=False)
.filter(archived=False) .filter(archived=False, limit_age__lte=user.age)
.filter(limit_age__lte=user.age) .annotate(
.annotate(order=F("product_type__order")) order=F("product_type__order"),
.annotate(category=F("product_type__name")) category=F("product_type__name"),
.annotate(category_comment=F("product_type__comment")) category_comment=F("product_type__comment"),
.annotate(price=F("selling_price")) # <-- selected price for basket validation price=F("selling_price"), # <-- selected price for basket validation
)
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to` .prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
) )
return [p for p in products if p.can_be_sold_to(user)] return [p for p in products if p.can_be_sold_to(user)]

View File

@@ -21,7 +21,7 @@ document.addEventListener("alpine:init", () => {
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) { if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
if ( if (
new Date(lastPurchaseTime) >= new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp)) new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) { ) {
this.basket = []; this.basket = [];
} }

View File

@@ -18,7 +18,9 @@
#basket { #basket {
min-width: 300px; min-width: 300px;
border-radius: 8px; border-radius: 8px;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px; box-shadow:
rgb(60 64 67 / 30%) 0 1px 3px 0,
rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px; padding: 10px;
} }

View File

@@ -77,6 +77,14 @@
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
</form> </form>
{% else %}
<div class="alert alert-yellow">
{% trans trimmed %}
Credit card payments are currently disabled on the eboutic.
You may still refill your account in one of the AE counters.
Please excuse us for the inconvenience.
{% endtrans %}
</div>
{% endif %} {% endif %}
{% if basket.contains_refilling_item %} {% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p> <p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>

View File

@@ -60,8 +60,6 @@ class CandidateForm(forms.ModelForm):
class VoteForm(forms.Form): class VoteForm(forms.Form):
def __init__(self, election: Election, user: User, *args, **kwargs): def __init__(self, election: Election, user: User, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not election.can_vote(user):
return
for role in election.roles.all(): for role in election.roles.all():
cand = role.candidatures cand = role.candidatures
if role.max_choice > 1: if role.max_choice > 1:
@@ -74,6 +72,7 @@ class VoteForm(forms.Form):
required=False, required=False,
widget=forms.RadioSelect(), widget=forms.RadioSelect(),
empty_label=_("Blank vote"), empty_label=_("Blank vote"),
blank=True,
) )

View File

@@ -14,6 +14,11 @@
{% block content %} {% block content %}
<h3 class="election__title">{{ election.title }}</h3> <h3 class="election__title">{{ election.title }}</h3>
{% if not user.has_perm("core.view_user") %}
<div class="alert alert-red">
{% trans %}Candidate pictures won't display for privacy reasons.{% endtrans %}
</div>
{% endif %}
<p class="election__description">{{ election.description }}</p> <p class="election__description">{{ election.description }}</p>
<hr> <hr>
<section class="election_details"> <section class="election_details">
@@ -117,7 +122,7 @@
{%- if role.max_choice == 1 and show_vote_buttons %} {%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn"> <div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %} {% set input_id = "blank_vote_" + role.id|string %}
<input id="{{ input_id }}" type="radio" name="{{ role.title }}"> <input id="{{ input_id }}" type="radio" name="{{ role.title }}" value="" checked>
<label for="{{ input_id }}"> <label for="{{ input_id }}">
<span>{% trans %}Choose blank vote{% endtrans %}</span> <span>{% trans %}Choose blank vote{% endtrans %}</span>
</label> </label>
@@ -185,26 +190,28 @@
</table> </table>
</form> </form>
</section> </section>
<section class="buttons"> {% if not user.is_anonymous %}
{%- if (election.can_candidate(user) and election.is_candidature_active) or (user.can_edit(election) and election.is_vote_editable) %}
<a class="button" href="{{ url('election:candidate', election_id=object.id) }}">{% trans %}Candidate{% endtrans %}</a>
{%- endif %}
{%- if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_list', election_id=object.id) }}">{% trans %}Add a new list{% endtrans %}</a>
{%- endif %}
{%- if user.can_edit(election) %}
{% if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_role', election_id=object.id) }}">{% trans %}Add a new role{% endtrans %}</a>
{% endif %}
<a class="button" href="{{ url('election:update', election_id=object.id) }}">{% trans %}Edit{% endtrans %}</a>
{%- endif %}
{%- if user.is_root %}
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %}
</section>
{%- if show_vote_buttons %}
<section class="buttons"> <section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button> {%- if (election.can_candidate(user) and election.is_candidature_active) or (user.can_edit(election) and election.is_vote_editable) %}
<a class="button" href="{{ url('election:candidate', election_id=object.id) }}">{% trans %}Candidate{% endtrans %}</a>
{%- endif %}
{%- if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_list', election_id=object.id) }}">{% trans %}Add a new list{% endtrans %}</a>
{%- endif %}
{%- if user.can_edit(election) %}
{% if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_role', election_id=object.id) }}">{% trans %}Add a new role{% endtrans %}</a>
{% endif %}
<a class="button" href="{{ url('election:update', election_id=object.id) }}">{% trans %}Edit{% endtrans %}</a>
{%- endif %}
{%- if user.is_root %}
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %}
</section> </section>
{%- endif %} {%- if show_vote_buttons %}
<section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button>
</section>
{%- endif %}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING
from cryptography.utils import cached_property from cryptography.utils import cached_property
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import (
LoginRequiredMixin, LoginRequiredMixin,
PermissionRequiredMixin, PermissionRequiredMixin,
@@ -10,8 +11,9 @@ from django.contrib.auth.mixins import (
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
@@ -53,7 +55,7 @@ class ElectionListArchivedView(CanViewMixin, ListView):
class ElectionDetailView(CanViewMixin, DetailView): class ElectionDetailView(CanViewMixin, DetailView):
"""Details an election responsability by responsability.""" """Details an election responsibility by responsibility."""
model = Election model = Election
template_name = "election/election_detail.jinja" template_name = "election/election_detail.jinja"
@@ -83,7 +85,7 @@ class ElectionDetailView(CanViewMixin, DetailView):
return super().get(request, *arg, **kwargs) return super().get(request, *arg, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add additionnal data to the template.""" """Add additional data to the template."""
user: User = self.request.user user: User = self.request.user
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"election_form": VoteForm(self.object, user), "election_form": VoteForm(self.object, user),
@@ -101,7 +103,7 @@ class ElectionDetailView(CanViewMixin, DetailView):
class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView): class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
"""Alows users to vote.""" """Allows users to vote."""
form_class = VoteForm form_class = VoteForm
template_name = "election/election_detail.jinja" template_name = "election/election_detail.jinja"
@@ -111,6 +113,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
return get_object_or_404(Election, pk=self.kwargs["election_id"]) return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self): def test_func(self):
if not self.election.can_vote(self.request.user):
return False
groups = set(self.election.vote_groups.values_list("id", flat=True)) groups = set(self.election.vote_groups.values_list("id", flat=True))
if ( if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups settings.SITH_GROUP_SUBSCRIBERS_ID in groups
@@ -150,11 +155,17 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
self.vote(data) self.vote(data)
return super().form_valid(form) return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _("Form is invalid"))
return redirect(
reverse("election:detail", kwargs={"election_id": self.election.id}),
)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add additionnal data to the template.""" """Add additional data to the template."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["object"] = self.election kwargs["object"] = self.election
kwargs["election"] = self.election kwargs["election"] = self.election

View File

@@ -1,6 +1,6 @@
import { default as ForceGraph3D } from "3d-force-graph"; import { default as ForceGraph3D } from "3d-force-graph";
import { forceX, forceY, forceZ } from "d3-force-3d"; import { forceX, forceY, forceZ } from "d3-force-3d";
// biome-ignore lint/style/noNamespaceImport: This is how it should be imported // biome-ignore lint/performance/noNamespaceImport: This is how it should be imported
import * as Three from "three"; import * as Three from "three";
import SpriteText from "three-spritetext"; import SpriteText from "three-spritetext";

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-19 23:10+0100\n" "POT-Creation-Date: 2026-02-08 16:14+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -3944,6 +3944,16 @@ msgstr "Solde restant : "
msgid "Pay with credit card" msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire" msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"Credit card payments are currently disabled on the eboutic. You may still "
"refill your account in one of the AE counters. Please excuse us for the "
"inconvenience."
msgstr ""
"Les paiements par carte bancaire sont actuellement désactivés sur l'eboutic. "
"Vous pouvez cependant toujours recharger votre compte dans un des lieux de vie de l'AE. "
"Veuillez nous excuser pour le désagrément."
#: eboutic/templates/eboutic/eboutic_checkout.jinja #: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "" msgid ""
"AE account payment disabled because your basket contains refilling items." "AE account payment disabled because your basket contains refilling items."
@@ -4108,6 +4118,12 @@ msgstr "Candidater"
msgid "Candidature are closed for this election" msgid "Candidature are closed for this election"
msgstr "Les candidatures sont fermées pour cette élection" msgstr "Les candidatures sont fermées pour cette élection"
#: election/templates/election/election_detail.jinja
msgid "Candidate pictures won't display for privacy reasons."
msgstr ""
"La photo du candidat ne s'affiche pas pour "
"des raisons de respect de la vie privée."
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "Polls close " msgid "Polls close "
msgstr "Votes fermés" msgstr "Votes fermés"
@@ -4183,6 +4199,10 @@ msgstr "au"
msgid "Polls open from" msgid "Polls open from"
msgstr "Votes ouverts du" msgstr "Votes ouverts du"
#: election/views.py
msgid "Form is invalid"
msgstr "Formulaire invalide"
#: forum/models.py #: forum/models.py
msgid "is a category" msgid "is a category"
msgstr "est une catégorie" msgstr "est une catégorie"

View File

@@ -12,7 +12,7 @@ export default defineConfig({
{ {
name: "@hey-api/client-fetch", name: "@hey-api/client-fetch",
baseUrl: false, baseUrl: false,
runtimeConfigPath: "./openapi-csrf.ts", runtimeConfigPath: resolve(__dirname, "./openapi-csrf.ts"),
exportFromIndex: true, exportFromIndex: true,
}, },
], ],

2210
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,9 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"sideEffects": [".css"], "sideEffects": [
".css"
],
"imports": { "imports": {
"#openapi": "./staticfiles/generated/openapi/client/index.ts", "#openapi": "./staticfiles/generated/openapi/client/index.ts",
"#core:*": "./core/static/bundled/*", "#core:*": "./core/static/bundled/*",
@@ -24,33 +26,33 @@
"#com:*": "./com/static/bundled/*" "#com:*": "./com/static/bundled/*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.5", "@babel/core": "^7.29.0",
"@babel/preset-env": "^7.28.5", "@babel/preset-env": "^7.29.0",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^2.3.14",
"@hey-api/openapi-ts": "^0.73.0", "@hey-api/openapi-ts": "^0.92.4",
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^6.4.1", "vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.4" "vite-plugin-static-copy": "^3.2.0"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.15.1", "@alpinejs/sort": "^3.15.8",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.4", "@floating-ui/dom": "^1.7.5",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^7.2.0",
"@fullcalendar/core": "^6.1.19", "@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.19", "@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/icalendar": "^6.1.19", "@fullcalendar/icalendar": "^6.1.20",
"@fullcalendar/list": "^6.1.19", "@fullcalendar/list": "^6.1.20",
"@sentry/browser": "^9.46.0", "@sentry/browser": "^10.38.0",
"@zip.js/zip.js": "^2.8.9", "@zip.js/zip.js": "^2.8.20",
"3d-force-graph": "^1.79.0", "3d-force-graph": "^1.79.1",
"alpinejs": "^3.15.1", "alpinejs": "^3.15.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"country-flag-emoji-polyfill": "^0.1.8", "country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
@@ -58,14 +60,14 @@
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6", "d3-force-3d": "^3.0.6",
"easymde": "^2.20.0", "easymde": "^2.20.0",
"glob": "^11.0.3", "glob": "^13.0.2",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.8",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.1", "lit-html": "^3.3.2",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"three": "^0.177.0", "three": "^0.182.0",
"three-spritetext": "^1.10.0", "three-spritetext": "^1.10.0",
"tom-select": "^2.4.3" "tom-select": "^2.5.1"
} }
} }

View File

@@ -1,4 +1,8 @@
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history"; import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import { ueFetchUeList } from "#openapi"; import { ueFetchUeList } from "#openapi";
const pageDefault = 1; const pageDefault = 1;
@@ -31,8 +35,8 @@ document.addEventListener("alpine:init", () => {
const url = getCurrentUrlParams(); const url = getCurrentUrlParams();
this.pushstate = History.Replace; this.pushstate = History.Replace;
this.page = Number.parseInt(url.get("page")) || pageDefault; this.page = Number.parseInt(url.get("page"), 10) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size")) || pageSizeDefault; this.page_size = Number.parseInt(url.get("page_size"), 10) || pageSizeDefault;
this.search = url.get("search") || ""; this.search = url.get("search") || "";
this.department = url.getAll("department"); this.department = url.getAll("department");
this.credit_type = url.getAll("credit_type"); this.credit_type = url.getAll("credit_type");

View File

@@ -19,37 +19,37 @@ authors = [
license = { text = "GPL-3.0-only" } license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12" requires-python = "<4.0,>=3.12"
dependencies = [ dependencies = [
"django>=5.2.8,<6.0.0", "django>=5.2.11,<6.0.0",
"django-ninja>=1.5.0,<6.0.0", "django-ninja>=1.5.3,<6.0.0",
"django-ninja-extra>=0.30.6", "django-ninja-extra>=0.31.0",
"Pillow>=12.0.0,<13.0.0", "Pillow>=12.1.1,<13.0.0",
"mistune>=3.1.4,<4.0.0", "mistune>=3.2.0,<4.0.0",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=46.0.3,<47.0.0", "cryptography>=46.0.5,<47.0.0",
"django-phonenumber-field>=8.3.0,<9.0.0", "django-phonenumber-field>=8.4.0,<9.0.0",
"phonenumbers>=9.0.18,<10.0.0", "phonenumbers>=9.0.23,<10.0.0",
"reportlab>=4.4.4,<5.0.0", "reportlab>=4.4.9,<5.0.0",
"django-haystack<4.0.0,>=3.3.0", "django-haystack<4.0.0,>=3.3.0",
"xapian-haystack<4.0.0,>=3.1.0", "xapian-haystack<4.0.0,>=3.1.0",
"libsass<1.0.0,>=0.23.0", "libsass<1.0.0,>=0.23.0",
"django-ordered-model<4.0.0,>=3.7.4", "django-ordered-model<4.0.0,>=3.7.4",
"django-simple-captcha<1.0.0,>=0.6.2", "django-simple-captcha<1.0.0,>=0.6.3",
"python-dateutil<3.0.0.0,>=2.9.0.post0", "python-dateutil<3.0.0.0,>=2.9.0.post0",
"sentry-sdk>=2.43.0,<3.0.0", "sentry-sdk>=2.52.0,<3.0.0",
"jinja2<4.0.0,>=3.1.6", "jinja2<4.0.0,>=3.1.6",
"django-countries>=8.0.0,<9.0.0", "django-countries>=8.2.0,<9.0.0",
"dict2xml>=1.7.7,<2.0.0", "dict2xml>=1.7.8,<2.0.0",
"Sphinx<6,>=5", "Sphinx<6,>=5",
"tomli>=2.3.0,<3.0.0", "tomli>=2.4.0,<3.0.0",
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0", "pydantic-extra-types>=2.11.0,<3.0.0",
"ical>=11.1.0,<12", "ical>=11.1.0,<12",
"redis[hiredis]<7,>=5.3.0", "redis[hiredis]>=5.3.0,<8.0.0",
"environs[django]>=14.5.0,<15.0.0", "environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0", "requests>=2.32.5,<3.0.0",
"honcho>=2.0.0", "honcho>=2.0.0",
"psutil>=7.1.3,<8.0.0", "psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.5.2", "celery[redis]>=5.6.2,<7",
"django-celery-results>=2.5.1", "django-celery-results>=2.5.1",
"django-celery-beat>=2.7.0", "django-celery-beat>=2.7.0",
] ]
@@ -60,32 +60,32 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups] [dependency-groups]
prod = [ prod = [
"psycopg[c]>=3.2.12,<4.0.0", "psycopg[c]>=3.3.2,<4.0.0",
] ]
dev = [ dev = [
"django-debug-toolbar>=6.1.0,<7", "django-debug-toolbar>=6.2.0,<7",
"ipython>=9.7.0,<10.0.0", "ipython>=9.10.0,<10.0.0",
"pre-commit>=4.3.0,<5.0.0", "pre-commit>=4.5.1,<5.0.0",
"ruff>=0.14.4,<1.0.0", "ruff>=0.15.0,<1.0.0",
"djhtml>=3.0.10,<4.0.0", "djhtml>=3.0.10,<4.0.0",
"faker>=37.12.0,<38.0.0", "faker>=40.4.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",
] ]
tests = [ tests = [
"freezegun>=1.5.5,<2.0.0", "freezegun>=1.5.5,<2.0.0",
"pytest>=8.4.2,<9.0.0", "pytest>=9.0.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0", "pytest-cov>=7.0.0,<8.0.0",
"pytest-django<5.0.0,>=4.10.0", "pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.20.4", "model-bakery<2.0.0,>=1.23.2",
"beautifulsoup4>=4.14.2,<5", "beautifulsoup4>=4.14.3,<5",
"lxml>=6.0.2,<7", "lxml>=6.0.2,<7",
] ]
docs = [ docs = [
"mkdocs<2.0.0,>=1.6.1", "mkdocs<2.0.0,>=1.6.1",
"mkdocs-material>=9.6.23,<10.0.0", "mkdocs-material>=9.7.1,<10.0.0",
"mkdocstrings>=0.30.1,<1.0.0", "mkdocstrings>=1.0.3,<2.0.0",
"mkdocstrings-python>=1.18.2,<2.0.0", "mkdocstrings-python>=2.0.2,<3.0.0",
"mkdocs-include-markdown-plugin>=7.2.0,<8.0.0", "mkdocs-include-markdown-plugin>=7.2.1,<8.0.0",
] ]
[tool.uv] [tool.uv]

View File

@@ -1,12 +1,12 @@
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api.ts";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { import {
type AlbumFetchAlbumData, type AlbumFetchAlbumData,
type AlbumSchema, type AlbumSchema,
albumFetchAlbum,
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
type PicturesUploadPictureErrors, type PicturesUploadPictureErrors,
albumFetchAlbum,
picturesFetchPictures, picturesFetchPictures,
picturesUploadPicture, picturesUploadPicture,
} from "#openapi"; } from "#openapi";
@@ -23,7 +23,7 @@ interface SubAlbumsConfig {
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("pictures", (config: AlbumPicturesConfig) => ({ Alpine.data("pictures", (config: AlbumPicturesConfig) => ({
pictures: [] as PictureSchema[], pictures: [] as PictureSchema[],
page: Number.parseInt(initialUrlParams.get("page")) || 1, page: Number.parseInt(initialUrlParams.get("page"), 10) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */, pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false, loading: false,
@@ -38,7 +38,10 @@ document.addEventListener("alpine:init", () => {
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
this.pushstate = History.Replace; this.pushstate = History.Replace;
this.page = this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1; Number.parseInt(
new URLSearchParams(window.location.search).get("page"),
10,
) || 1;
}); });
}, },

View File

@@ -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 { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; 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 AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi"; import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi";
@registerComponent("album-ajax-select") @registerComponent("album-ajax-select")

View File

@@ -30,8 +30,8 @@ document.addEventListener("alpine:init", () => {
const zipWriter = new ZipWriter(await fileHandle.createWritable()); const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all( await Promise.all(
this.downloadPictures.map(async (p: PictureSchema) => { this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), { return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9, level: 9,
lastModDate: new Date(p.date), lastModDate: new Date(p.date),

View File

@@ -1,4 +1,4 @@
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api.ts";
import { import {
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
@@ -27,7 +27,7 @@ document.addEventListener("alpine:init", () => {
const lastCachedNumber = localStorage.getItem(localStorageInvalidationKey); const lastCachedNumber = localStorage.getItem(localStorageInvalidationKey);
if ( if (
lastCachedNumber !== null && lastCachedNumber !== null &&
Number.parseInt(lastCachedNumber) === config.nbPictures Number.parseInt(lastCachedNumber, 10) === config.nbPictures
) { ) {
return JSON.parse(localStorage.getItem(localStorageKey)); return JSON.parse(localStorage.getItem(localStorageKey));
} }

View File

@@ -1,21 +1,21 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history";
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
import { paginated } from "#core:utils/api.ts";
import { exportToHtml } from "#core:utils/globals.ts";
import { History } from "#core:utils/history.ts";
import { import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type ModerationRequestSchema, type ModerationRequestSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchIdentificationsResponse, type PicturesFetchIdentificationsResponse,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
type UserProfileSchema,
picturesDeletePicture, picturesDeletePicture,
picturesFetchIdentifications, picturesFetchIdentifications,
picturesFetchModerationRequests, picturesFetchModerationRequests,
picturesFetchPictures, picturesFetchPictures,
picturesIdentifyUsers, picturesIdentifyUsers,
picturesModeratePicture, picturesModeratePicture,
type UserProfileSchema,
usersidentifiedDeleteRelation, usersidentifiedDeleteRelation,
} from "#openapi"; } from "#openapi";
@@ -208,7 +208,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} }
this.pushstate = History.Replace; this.pushstate = History.Replace;
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId), (i: PictureSchema) =>
i.id === Number.parseInt(event.state.sasPictureId, 10),
); );
}); });
this.pushstate = History.Replace; /* Avoid first url push */ this.pushstate = History.Replace; /* Avoid first url push */
@@ -301,7 +302,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id, picture_id: this.currentPicture.id,
}, },
body: widget.items.map((i: string) => Number.parseInt(i)), body: widget.items.map((i: string) => Number.parseInt(i, 10)),
}); });
// refresh the identified users list // refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true }); await this.currentPicture.loadIdentifications({ forceReload: true });

View File

@@ -8,9 +8,9 @@ document.addEventListener("alpine:init", () => {
async init() { async init() {
const userSelect = document.getElementById("id_member") as HTMLSelectElement; const userSelect = document.getElementById("id_member") as HTMLSelectElement;
userSelect.addEventListener("change", async () => { userSelect.addEventListener("change", async () => {
await this.loadProfile(Number.parseInt(userSelect.value)); await this.loadProfile(Number.parseInt(userSelect.value, 10));
}); });
await this.loadProfile(Number.parseInt(userSelect.value)); await this.loadProfile(Number.parseInt(userSelect.value, 10));
}, },
async loadProfile(userId: number) { async loadProfile(userId: number) {

View File

@@ -1,4 +1,5 @@
import { BarController, BarElement, CategoryScale, Chart, LinearScale } from "chart.js"; import { BarController, BarElement, CategoryScale, Chart, LinearScale } from "chart.js";
Chart.register(BarController, BarElement, CategoryScale, LinearScale); Chart.register(BarController, BarElement, CategoryScale, LinearScale);
function getRandomColor() { function getRandomColor() {
@@ -18,13 +19,11 @@ function getRandomColorUniq(list: string[]) {
} }
function hexToRgb(hex: string) { function hexToRgb(hex: string) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
// biome-ignore lint/performance/useTopLevelRegex: Performance impact is minimal
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => { const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => {
return r + r + g + g + b + b; return r + r + g + g + b + b;
}); });
// biome-ignore lint/performance/useTopLevelRegex: Performance impact is minimal
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexrgb); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexrgb);
return result return result
? { ? {

View File

@@ -63,10 +63,10 @@ function parseSlots(s: string): TimetableSlot[] {
} }
const [startHour, startMin] = parsed.groups.startHour const [startHour, startMin] = parsed.groups.startHour
.split(":") .split(":")
.map((i) => Number.parseInt(i)); .map((i) => Number.parseInt(i, 10));
const [endHour, endMin] = parsed.groups.endHour const [endHour, endMin] = parsed.groups.endHour
.split(":") .split(":")
.map((i) => Number.parseInt(i)); .map((i) => Number.parseInt(i, 10));
return { return {
...parsed.groups, ...parsed.groups,
startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT), startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT),
@@ -157,6 +157,7 @@ document.addEventListener("alpine:init", () => {
.map((c: TimetableSlot) => c.startHour) .map((c: TimetableSlot) => c.startHour)
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00") .reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
.split(":")[0], .split(":")[0],
10,
); );
const res: [string, object][] = []; const res: [string, object][] = [];
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) { for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {

646
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
import { parse, resolve } from "node:path"; import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject"; import inject from "@rollup/plugin-inject";
import { glob } from "glob"; import { glob } from "glob";
import { type AliasOptions, type UserConfig, defineConfig } from "vite";
import type { Rollup } from "vite"; import type { Rollup } from "vite";
import { type AliasOptions, defineConfig, type UserConfig } from "vite";
import tsconfig from "./tsconfig.json"; import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(__dirname, "./staticfiles/generated/bundled");