4 Commits

Author SHA1 Message Date
TitouanDor
4b9d64597f move form test into a class TestElectionForm 2026-02-08 18:46:03 +01:00
TitouanDor
01eb88ce5d add test with wrong data form 2026-01-22 13:30:08 +01:00
TitouanDor
8d74d18a25 add test_election_form 2026-01-20 22:14:56 +01:00
Sli
2744282fd8 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-01-14 11:45:12 +01:00
60 changed files with 2199 additions and 1439 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.15.0 rev: v0.14.4
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@2.3.14"] additional_dependencies: ["@biomejs/biome@1.9.4"]
- 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,34 +7,20 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**/static/**"] "ignore": ["*.min.*", "staticfiles/generated"]
}, },
"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": {
"recommended": true, "all": 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,6 +26,7 @@ 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
@@ -186,6 +187,9 @@ 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)
@@ -206,15 +210,24 @@ 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)
@cached_property
def current_members(self) -> list[Membership]:
return list(self.members.ongoing().select_related("user").order_by("-role"))
def get_membership_for(self, user: User) -> Membership | None: def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user.""" """Return the current membership the given user.
Note:
The result is cached.
"""
if user.is_anonymous: if user.is_anonymous:
return None return None
return next((m for m in self.current_members if m.user_id == user.id), 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
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)
@@ -232,7 +245,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 `ongoing` queryset method mind combining this with the :meth:`ongoing` queryset method
""" """
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
@@ -275,29 +288,42 @@ class MembershipQuerySet(models.QuerySet):
) )
def update(self, **kwargs) -> int: def update(self, **kwargs) -> int:
"""Remove users from club groups they are no more in """Refresh the cache and edit group ownership.
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 retrieve the updated memberships one to perform group removal and one to perform
- one to perform group removal group attribution.
- 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 edit club groups # if no row was affected, no need to refresh the cache
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 also remove the concerned users from the club groups. but add a cache invalidation for the elements of the queryset
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 :
@@ -313,6 +339,12 @@ 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
@@ -376,6 +408,9 @@ 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})
@@ -396,6 +431,7 @@ 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,6 +72,25 @@ 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)
@@ -93,6 +112,24 @@ 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,3 +1,5 @@
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";
@@ -6,8 +8,6 @@ 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,7 +95,6 @@ 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.ts"; import { exportToHtml } from "#core:utils/globals";
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, 10); maxChoices = Number.parseInt(value);
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,3 +1,4 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select"; import TomSelect from "tom-select";
import type { import type {
RecursivePartial, RecursivePartial,
@@ -6,7 +7,6 @@ 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, 10) ?? null; this.delay = Number.parseInt(newValue) ?? 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, 10) ?? null; this.max = Number.parseInt(newValue) ?? null;
break; break;
} }
case "min-characters-for-search": { case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue, 10) ?? 0; this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break; break;
} }
default: { default: {

View File

@@ -1,20 +1,21 @@
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 {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { import {
type GroupSchema, type GroupSchema,
groupSearchGroup,
type SithFileSchema, type SithFileSchema,
sithfileSearchFiles,
type UserProfileSchema, type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers, userSearchUsers,
} from "#openapi"; } from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
@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 {
markdownRenderMarkdown,
type UploadUploadImageErrors, type UploadUploadImageErrors,
markdownRenderMarkdown,
uploadUploadImage, uploadUploadImage,
} from "#openapi"; } from "#openapi";

View File

@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
/** /**
* 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.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
@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.ts"; import { exportToHtml } from "#core:utils/globals";
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, 10), Number.parseInt(element.getAttribute("show-more") as string),
{ {
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 @@
// biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation import { exportToHtml } from "#core:utils/globals";
// 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,6 +8,7 @@
// 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;
@@ -27,6 +28,7 @@ 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;
@@ -72,6 +74,7 @@ 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,3 +1,4 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, { import cytoscape, {
type ElementDefinition, type ElementDefinition,
type NodeSingular, type NodeSingular,
@@ -5,8 +6,7 @@ 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 { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts"; import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
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), 10); const value = Number.parseInt(initialUrlParams.get(prop));
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.ts"; import type { NestedKeyOf } from "#core:utils/types";
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,6 +10,7 @@ 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;
} }

124
core/static/core/js/shorten.min.js vendored Normal file
View File

@@ -0,0 +1,124 @@
// 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,17 +22,19 @@ 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 from club.models import Club, Membership
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
@@ -434,6 +436,23 @@ 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),
(subscriber_user.make, 403), (lambda: subscriber_user.make(), 403),
(old_subscriber_user.make, 403), (lambda: old_subscriber_user.make(), 403),
(board_user.make, 403), (lambda: 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_recipe", "file", "expected_status"), ("user_receipe", "file", "expected_status"),
[ [
( (
lambda: None, lambda: None,
@@ -279,21 +279,21 @@ def test_apply_rights_recursively():
403, 403,
), ),
( (
subscriber_user.make, lambda: 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,
), ),
( (
old_subscriber_user.make, lambda: 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,
), ),
( (
old_subscriber_user.make, lambda: 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
( (
old_subscriber_user.make, lambda: 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,
), ),
( (
old_subscriber_user.make, lambda: 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
), ),
( (
old_subscriber_user.make, lambda: 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_recipe: Callable[[], User | None], user_receipe: Callable[[], User | None],
file: UploadedFile | None, file: UploadedFile | None,
expected_status: int, expected_status: int,
): ):
if (user := user_recipe()) is not None: if (user := user_receipe()) 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,7 +418,9 @@ 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("user_factory", [lambda: baker.make(User), AnonymousUser]) @pytest.mark.parametrize(
"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().filter( AccountDump.objects.ongoing()
customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold .filter(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,7 +447,8 @@ 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
return any(user.is_in_group(pk=group.id) for group in buying_groups) res = 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.ts"; import type { Product } from "#counter:counter/types";
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,
counterSearchCounter,
type ProductTypeSchema, type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
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", 10), parsed[2]]; return [Number.parseInt(parsed[1] || "1"), 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-expect-error documentation says it's fine, specified type is wrong // @ts-ignore documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
// @ts-expect-error documentation says it's fine, specified type is wrong // @ts-ignore 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.ts"; import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket.ts"; import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts"; import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
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,13 +1,9 @@
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"), 10); Number.parseInt(elem.getAttribute("x-sort:item"));
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)" :key="item.product.id"> <template x-for="(item, index) in Object.values(basket)">
<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,13 +43,12 @@ 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, limit_age__lte=user.age) .filter(archived=False)
.annotate( .filter(limit_age__lte=user.age)
order=F("product_type__order"), .annotate(order=F("product_type__order"))
category=F("product_type__name"), .annotate(category=F("product_type__name"))
category_comment=F("product_type__comment"), .annotate(category_comment=F("product_type__comment"))
price=F("selling_price"), # <-- selected price for basket validation .annotate(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, 10)) new Date(Number.parseInt(localStorage.basketTimestamp))
) { ) {
this.basket = []; this.basket = [];
} }

View File

@@ -18,9 +18,7 @@
#basket { #basket {
min-width: 300px; min-width: 300px;
border-radius: 8px; border-radius: 8px;
box-shadow: box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
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,14 +77,6 @@
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

@@ -14,9 +14,9 @@
{% 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") %} {% if user.is_anonymous %}
<div class="alert alert-red"> <div class="alert alert-red">
{% trans %}Candidate pictures won't display for privacy reasons.{% endtrans %} {% trans %}You are not logged in, candidate pictures won't display for privacy reasons.{% endtrans %}
</div> </div>
{% endif %} {% endif %}
<p class="election__description">{{ election.description }}</p> <p class="election__description">{{ election.description }}</p>

View File

@@ -1,6 +1,7 @@
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from pytest_django.asserts import assertRedirects
from django.conf import settings from django.conf import settings
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -51,7 +52,137 @@ class TestElectionUpdateView(TestElection):
) )
assert response.status_code == 403 assert response.status_code == 403
class TestElectionForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.election = baker.make(
Election,
end_date = now() + timedelta(days=1),
)
cls.group = baker.make(Group)
cls.election.vote_groups.add(cls.group)
cls.election.edit_groups.add(cls.group)
lists = baker.make(ElectionList, election=cls.election, _quantity=2, _bulk_create=True)
cls.roles = baker.make(Role, election=cls.election, _quantity=2, _bulk_create=True)
users = baker.make(User, _quantity=4, _bulk_create=True)
cls.cand = [
baker.make(Candidature, role=cls.roles[0], user=users[0], election_list=lists[0]),
baker.make(Candidature, role=cls.roles[0], user=users[1], election_list=lists[1]),
baker.make(Candidature, role=cls.roles[1], user=users[2], election_list=lists[0]),
baker.make(Candidature, role=cls.roles[1], user=users[3], election_list=lists[1]),
]
cls.url = reverse("election:vote", kwargs={"election_id": cls.election.id})
def test_election_good_form(self):
election = self.election
group = self.group
roles = self.roles
cand = self.cand
votes = [
{
roles[0].title : "",
roles[1].title : str(cand[2].id),
},
{
roles[0].title : "",
roles[1].title : "",
},
{
roles[0].title : str(cand[0].id),
roles[1].title : str(cand[2].id),
},
{
roles[0].title : str(cand[0].id),
roles[1].title : str(cand[3].id),
},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
group.users.set(voters)
for voter, vote in zip(voters, votes):
assert election.can_vote(voter)
self.client.force_login(voter)
response = self.client.post(self.url, data = vote)
assertRedirects(
response,
reverse("election:detail", kwargs={"election_id": election.id})
)
assert set(election.voters.all()) == set(voters)
assert election.results == {
roles[0].title: {
cand[0].user.username: {"percent": 50.0, "vote": 2},
cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 50.0, "vote": 2},
"total vote": 4,
},
roles[1].title: {
cand[2].user.username: {"percent": 50.0, "vote": 2},
cand[3].user.username: {"percent": 25.0, "vote": 1},
"blank vote": {"percent": 25.0, "vote": 1},
"total vote": 4,
},
}
def test_election_bad_form(self):
election = self.election
group = self.group
roles = self.roles
cand = self.cand
unknow_user = baker.make(User, _quantity=1, _bulk_create=True)
votes = [
{
roles[0].title : "",
roles[1].title : str(cand[0].id), #wrong candidate
},
{
roles[0].title : "",
},
{
roles[0].title : "0123456789", #unknow users
roles[1].title : str(unknow_user[0].id), #not a candidate
},
{
},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
group.users.set(voters)
for voter, vote in zip(voters, votes):
assert election.can_vote(voter)
self.client.force_login(voter)
response = self.client.post(self.url, data = vote)
assertRedirects(
response,
reverse("election:detail", kwargs={"election_id": election.id})
)
assert election.results == {
roles[0].title: {
cand[0].user.username: {"percent": 0.0, "vote": 0},
cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
roles[1].title: {
cand[2].user.username: {"percent": 0.0, "vote": 0},
cand[3].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
}
@pytest.mark.django_db @pytest.mark.django_db
def test_election_create_list_permission(client: Client): def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1)) election = baker.make(Election, end_candidature=now() + timedelta(hours=1))
@@ -114,4 +245,4 @@ def test_election_results():
"blank vote": {"percent": 25.0, "vote": 25}, "blank vote": {"percent": 25.0, "vote": 25},
"total vote": 100, "total vote": 100,
}, },
} }

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/performance/noNamespaceImport: This is how it should be imported // biome-ignore lint/style/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: 2026-02-08 16:14+0100\n" "POT-Creation-Date: 2026-01-14 11:34+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,16 +3944,6 @@ 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."
@@ -4119,10 +4109,9 @@ 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 #: election/templates/election/election_detail.jinja
msgid "Candidate pictures won't display for privacy reasons." msgid ""
msgstr "" "You are not logged in, candidate pictures won't display for privacy reasons."
"La photo du candidat ne s'affiche pas pour " msgstr "Vous n'êtes pas connecté, les photos des candidats ne s'afficheront pas pour des raisons de respect de la vie privée."
"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 "

View File

@@ -12,7 +12,7 @@ export default defineConfig({
{ {
name: "@hey-api/client-fetch", name: "@hey-api/client-fetch",
baseUrl: false, baseUrl: false,
runtimeConfigPath: resolve(__dirname, "./openapi-csrf.ts"), runtimeConfigPath: "./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,9 +15,7 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"sideEffects": [ "sideEffects": [".css"],
".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/*",
@@ -26,33 +24,33 @@
"#com:*": "./com/static/bundled/*" "#com:*": "./com/static/bundled/*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.28.5",
"@babel/preset-env": "^7.29.0", "@babel/preset-env": "^7.28.5",
"@biomejs/biome": "^2.3.14", "@biomejs/biome": "^1.9.4",
"@hey-api/openapi-ts": "^0.92.4", "@hey-api/openapi-ts": "^0.73.0",
"@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": "^7.3.1", "vite": "^6.4.1",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.2.0" "vite-plugin-static-copy": "^3.1.4"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.15.8", "@alpinejs/sort": "^3.15.1",
"@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.5", "@floating-ui/dom": "^1.7.4",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/icalendar": "^6.1.20", "@fullcalendar/icalendar": "^6.1.19",
"@fullcalendar/list": "^6.1.20", "@fullcalendar/list": "^6.1.19",
"@sentry/browser": "^10.38.0", "@sentry/browser": "^9.46.0",
"@zip.js/zip.js": "^2.8.20", "@zip.js/zip.js": "^2.8.9",
"3d-force-graph": "^1.79.1", "3d-force-graph": "^1.79.0",
"alpinejs": "^3.15.8", "alpinejs": "^3.15.1",
"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",
@@ -60,14 +58,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": "^13.0.2", "glob": "^11.0.3",
"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.2", "lit-html": "^3.3.1",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"three": "^0.182.0", "three": "^0.177.0",
"three-spritetext": "^1.10.0", "three-spritetext": "^1.10.0",
"tom-select": "^2.5.1" "tom-select": "^2.4.3"
} }
} }

View File

@@ -1,8 +1,4 @@
import { import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import { ueFetchUeList } from "#openapi"; import { ueFetchUeList } from "#openapi";
const pageDefault = 1; const pageDefault = 1;
@@ -35,8 +31,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"), 10) || pageDefault; this.page = Number.parseInt(url.get("page")) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size"), 10) || pageSizeDefault; this.page_size = Number.parseInt(url.get("page_size")) || 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.11,<6.0.0", "django>=5.2.8,<6.0.0",
"django-ninja>=1.5.3,<6.0.0", "django-ninja>=1.5.0,<6.0.0",
"django-ninja-extra>=0.31.0", "django-ninja-extra>=0.30.6",
"Pillow>=12.1.1,<13.0.0", "Pillow>=12.0.0,<13.0.0",
"mistune>=3.2.0,<4.0.0", "mistune>=3.1.4,<4.0.0",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=46.0.5,<47.0.0", "cryptography>=46.0.3,<47.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0", "django-phonenumber-field>=8.3.0,<9.0.0",
"phonenumbers>=9.0.23,<10.0.0", "phonenumbers>=9.0.18,<10.0.0",
"reportlab>=4.4.9,<5.0.0", "reportlab>=4.4.4,<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.3", "django-simple-captcha<1.0.0,>=0.6.2",
"python-dateutil<3.0.0.0,>=2.9.0.post0", "python-dateutil<3.0.0.0,>=2.9.0.post0",
"sentry-sdk>=2.52.0,<3.0.0", "sentry-sdk>=2.43.0,<3.0.0",
"jinja2<4.0.0,>=3.1.6", "jinja2<4.0.0,>=3.1.6",
"django-countries>=8.2.0,<9.0.0", "django-countries>=8.0.0,<9.0.0",
"dict2xml>=1.7.8,<2.0.0", "dict2xml>=1.7.7,<2.0.0",
"Sphinx<6,>=5", "Sphinx<6,>=5",
"tomli>=2.4.0,<3.0.0", "tomli>=2.3.0,<3.0.0",
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.0,<3.0.0", "pydantic-extra-types>=2.10.6,<3.0.0",
"ical>=11.1.0,<12", "ical>=11.1.0,<12",
"redis[hiredis]>=5.3.0,<8.0.0", "redis[hiredis]<7,>=5.3.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.2.2,<8.0.0", "psutil>=7.1.3,<8.0.0",
"celery[redis]>=5.6.2,<7", "celery[redis]>=5.5.2",
"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.3.2,<4.0.0", "psycopg[c]>=3.2.12,<4.0.0",
] ]
dev = [ dev = [
"django-debug-toolbar>=6.2.0,<7", "django-debug-toolbar>=6.1.0,<7",
"ipython>=9.10.0,<10.0.0", "ipython>=9.7.0,<10.0.0",
"pre-commit>=4.5.1,<5.0.0", "pre-commit>=4.3.0,<5.0.0",
"ruff>=0.15.0,<1.0.0", "ruff>=0.14.4,<1.0.0",
"djhtml>=3.0.10,<4.0.0", "djhtml>=3.0.10,<4.0.0",
"faker>=40.4.0,<41.0.0", "faker>=37.12.0,<38.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>=9.0.2,<10.0.0", "pytest>=8.4.2,<9.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.23.2", "model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.14.3,<5", "beautifulsoup4>=4.14.2,<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.7.1,<10.0.0", "mkdocs-material>=9.6.23,<10.0.0",
"mkdocstrings>=1.0.3,<2.0.0", "mkdocstrings>=0.30.1,<1.0.0",
"mkdocstrings-python>=2.0.2,<3.0.0", "mkdocstrings-python>=1.18.2,<2.0.0",
"mkdocs-include-markdown-plugin>=7.2.1,<8.0.0", "mkdocs-include-markdown-plugin>=7.2.0,<8.0.0",
] ]
[tool.uv] [tool.uv]
@@ -141,4 +141,4 @@ sith = "sith.pytest"
[tool.pytest.ini_options] [tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "sith.settings" DJANGO_SETTINGS_MODULE = "sith.settings"
python_files = ["tests.py", "test_*.py", "*_tests.py"] python_files = ["tests.py", "test_*.py", "*_tests.py"]
markers = ["slow"] markers = ["slow"]

View File

@@ -1,12 +1,12 @@
import { paginated } from "#core:utils/api.ts"; import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
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"), 10) || 1, page: Number.parseInt(initialUrlParams.get("page")) || 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,10 +38,7 @@ document.addEventListener("alpine:init", () => {
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
this.pushstate = History.Replace; this.pushstate = History.Replace;
this.page = this.page =
Number.parseInt( Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
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((p: PictureSchema) => { this.downloadPictures.map(async (p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const imgName = `${p.album}/IMG_${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.ts"; import { paginated } from "#core:utils/api";
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, 10) === config.nbPictures Number.parseInt(lastCachedNumber) === 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,8 +208,7 @@ 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: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId),
i.id === Number.parseInt(event.state.sasPictureId, 10),
); );
}); });
this.pushstate = History.Replace; /* Avoid first url push */ this.pushstate = History.Replace; /* Avoid first url push */
@@ -302,7 +301,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, 10)), body: widget.items.map((i: string) => Number.parseInt(i)),
}); });
// 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, 10)); await this.loadProfile(Number.parseInt(userSelect.value));
}); });
await this.loadProfile(Number.parseInt(userSelect.value, 10)); await this.loadProfile(Number.parseInt(userSelect.value));
}, },
async loadProfile(userId: number) { async loadProfile(userId: number) {

View File

@@ -1,5 +1,4 @@
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() {
@@ -19,11 +18,13 @@ 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, 10)); .map((i) => Number.parseInt(i));
const [endHour, endMin] = parsed.groups.endHour const [endHour, endMin] = parsed.groups.endHour
.split(":") .split(":")
.map((i) => Number.parseInt(i, 10)); .map((i) => Number.parseInt(i));
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,7 +157,6 @@ 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) {

636
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");