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
66 changed files with 2209 additions and 1555 deletions

View File

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

View File

@@ -7,34 +7,20 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**/static/**"]
"ignore": ["*.min.*", "staticfiles/generated"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"lineWidth": 88
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"useNamingConvention": "error"
},
"performance": {
"noNamespaceImport": "error"
},
"suspicious": {
"noConsole": {
"level": "error",
"options": { "allow": ["error", "warn"] }
}
},
"correctness": {
"noUnusedVariables": "error",
"noUndeclaredVariables": "error",
"noUndeclaredDependencies": "error"
}
"all": true
}
},
"javascript": {

View File

@@ -26,6 +26,7 @@ from __future__ import annotations
from typing import Iterable, Self
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
@@ -186,6 +187,9 @@ class Club(models.Model):
self.page.save(force_lock=True)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}")
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
@@ -206,15 +210,24 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given 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:
"""Return the current membership of the given user."""
"""Return the current membership the given user.
Note:
The result is cached.
"""
if user.is_anonymous:
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:
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.
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)
@@ -275,29 +288,42 @@ class MembershipQuerySet(models.QuerySet):
)
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.
Be aware that this adds three db queries :
- one to retrieve the updated memberships
- one to perform group removal
- and one to perform group attribution.
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
"""
nb_rows = super().update(**kwargs)
if nb_rows == 0:
# if no row was affected, no need to edit club groups
# if no row was affected, no need to refresh the cache
return 0
cache_memberships = {}
memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones
# It's more concise to write and more reliable
Membership._remove_club_groups(memberships)
Membership._add_club_groups(memberships)
for member in memberships:
cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else:
cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships)
return nb_rows
def delete(self) -> tuple[int, dict[str, int]]:
"""Work just like the default Django's delete() method,
but 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 :
@@ -313,6 +339,12 @@ class MembershipQuerySet(models.QuerySet):
nb_rows, rows_counts = super().delete()
if nb_rows > 0:
Membership._remove_club_groups(memberships)
cache.set_many(
{
f"membership_{m.club_id}_{m.user_id}": "not_member"
for m in memberships
}
)
return nb_rows, rows_counts
@@ -376,6 +408,9 @@ class Membership(models.Model):
self._remove_club_groups([self])
if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id})
@@ -396,6 +431,7 @@ class Membership(models.Model):
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(

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 { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")

View File

@@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one
{% csrf_token %}
{{ form }}
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
<p><input type="submit" value="{% trans %}Download as CSV{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
<p><input type="submit" value="{% trans %}Download as cvs{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
</form>
<p>
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>

View File

@@ -72,6 +72,25 @@ class TestMembershipQuerySet(TestClub):
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
@@ -93,6 +112,24 @@ class TestMembershipQuerySet(TestClub):
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
mem_comptable = self.president.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
cache.set(
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
)
# should delete the subscriptions of simple_board_member and president
self.club.members.ongoing().board().delete()
for membership in (mem_skia, mem_comptable):
cached_mem = cache.get(
f"membership_{membership.club_id}_{membership.user_id}"
)
assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)

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

View File

@@ -12,7 +12,7 @@ from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
from core.models import Group, User, UserBan
from core.models import Group, User
from counter.models import (
Counter,
Customer,
@@ -40,7 +40,6 @@ class Command(BaseCommand):
self.stdout.write("Creating users...")
users = self.create_users()
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
@@ -89,8 +88,6 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self) -> list[User]:
# Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
users = [
User(
@@ -117,33 +114,14 @@ class Command(BaseCommand):
public_group.users.add(*users)
return users
def create_bans(self, users: list[User]):
ban_groups = [
settings.SITH_GROUP_BANNED_COUNTER_ID,
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
]
UserBan.objects.bulk_create(
[
UserBan(
user=user,
ban_group_id=i,
reason=self.faker.sentence(),
expires_at=make_aware(self.faker.future_datetime("+1y")),
)
for user in users
for i in random.sample(ban_groups, k=random.randint(1, len(ban_groups)))
]
)
def create_subscriptions(self, users: list[User]):
def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
s = Subscription(member=_user, payment_method=payment_method)
s.subscription_start = s.compute_start(d=start_date, duration=duration)
s.subscription_end = s.compute_end(duration)
return s
sub = Subscription(member=_user, payment_method=payment_method)
sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
sub.subscription_end = sub.compute_end(duration)
return sub
subscriptions = []
customers = []

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 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.magic("notifications", notificationPlugin);

View File

@@ -56,7 +56,7 @@ export function limitedChoices(Alpine: AlpineType) {
effect(() => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10);
maxChoices = Number.parseInt(value);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// 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 type {
RecursivePartial,
@@ -6,7 +7,6 @@ import type {
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
@@ -29,7 +29,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue, 10) ?? null;
this.delay = Number.parseInt(newValue) ?? null;
break;
}
case "placeholder": {
@@ -37,11 +37,11 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
break;
}
case "max": {
this.max = Number.parseInt(newValue, 10) ?? null;
this.max = Number.parseInt(newValue) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue, 10) ?? 0;
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break;
}
default: {

View File

@@ -1,20 +1,21 @@
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 { 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 {
type GroupSchema,
groupSearchGroup,
type SithFileSchema,
sithfileSearchFiles,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
@registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {}

View File

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

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")
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 { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components.ts";
@registerComponent("ui-tab")
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", () => {
const navbar = document.getElementById("navbar-content");

View File

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

View File

@@ -1,9 +1,9 @@
import {
type Placement,
autoPlacement,
computePosition,
flip,
offset,
type Placement,
size,
} 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 { exportToHtml } from "#core:utils/globals.ts";
interface LoggedUser {
name: string;

View File

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

View File

@@ -1,3 +1,4 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
@@ -5,8 +6,7 @@ import cytoscape, {
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
@@ -200,7 +200,7 @@ document.addEventListener("alpine:init", () => {
isZoomEnabled: !isMobile(),
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) {
return defaultDepth;
}

View File

@@ -1,5 +1,5 @@
import { client, type Options } from "#openapi";
import type { Client, RequestResult, TDataShape } from "#openapi:client";
import { type Options, client } from "#openapi";
export interface PaginatedResponse<T> {
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> {
/** 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);
} catch (e) {
if (e instanceof DOMException) {
// biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
console.warn(e.message);
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.models import Permission
from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View
from django.views.generic.base import ContextMixin
from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
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.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo
@@ -434,6 +436,23 @@ class TestUserIsInGroup(TestCase):
with self.assertNumQueries(0):
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):
"""Test that searching for a not existing group
returns False.

View File

@@ -118,9 +118,9 @@ class TestFileModerationView:
(lambda: None, 403), # Anonymous user
(lambda: baker.make(User, is_superuser=True), 200),
(lambda: baker.make(User), 403),
(subscriber_user.make, 403),
(old_subscriber_user.make, 403),
(board_user.make, 403),
(lambda: subscriber_user.make(), 403),
(lambda: old_subscriber_user.make(), 403),
(lambda: board_user.make(), 403),
],
)
def test_view_access(
@@ -262,7 +262,7 @@ def test_apply_rights_recursively():
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_recipe", "file", "expected_status"),
("user_receipe", "file", "expected_status"),
[
(
lambda: None,
@@ -279,21 +279,21 @@ def test_apply_rights_recursively():
403,
),
(
subscriber_user.make,
lambda: subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
old_subscriber_user.make,
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
old_subscriber_user.make,
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg",
content=RED_PIXEL_PNG,
@@ -302,21 +302,21 @@ def test_apply_rights_recursively():
200,
), # very long file name
(
old_subscriber_user.make,
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=b"invalid", content_type="image/jpg"
),
422,
),
(
old_subscriber_user.make,
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
),
200, # PIL can guess
),
(
old_subscriber_user.make,
lambda: old_subscriber_user.make(),
SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"),
422,
),
@@ -324,11 +324,11 @@ def test_apply_rights_recursively():
)
def test_quick_upload_image(
client: Client,
user_recipe: Callable[[], User | None],
user_receipe: Callable[[], User | None],
file: UploadedFile | None,
expected_status: int,
):
if (user := user_recipe()) is not None:
if (user := user_receipe()) is not None:
client.force_login(user)
resp = client.post(
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)
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):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)

View File

@@ -39,9 +39,8 @@ class ProductAdmin(SearchModelAdmin):
"code",
"product_type",
"selling_price",
"profit",
"archived",
"created_at",
"updated_at",
)
list_select_related = ("product_type",)
search_fields = ("name", "code")

View File

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

View File

@@ -1,67 +0,0 @@
# Generated by Django 5.2.8 on 2026-02-10 15:40
from operator import attrgetter
import django.utils.timezone
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import OuterRef, Subquery
from counter.models import Selling
def apply_product_history_dates(apps: StateApps, schema_editor):
"""Approximate a posteriori the value of created_at and updated_at."""
Product = apps.get_model("counter", "Product")
sales_subquery = Selling.objects.filter(product=OuterRef("pk")).values("date")
# for products that have an associated sale, we set the creation date
# to the one of the first sale, and the update date to the one of the last sale
products = list(
Product.objects.exclude(sellings=None)
.annotate(
new_created_at=Subquery(sales_subquery.order_by("date")[:1]),
new_updated_at=Subquery(sales_subquery.order_by("-date")[:1]),
)
.only("id")
)
for product in products:
product.created_at = product.new_created_at
product.updated_at = product.new_updated_at
# For the remaining products (those without sale),
# they are given the creation and update date of the previous product having sales.
products_without_sale = list(Product.objects.filter(sellings=None).only("id"))
for product in products_without_sale:
previous_product = max(
(p for p in products if p.id < product.id), key=attrgetter("id")
)
product.created_at = previous_product.created_at
product.updated_at = previous_product.updated_at
products.extend(products_without_sale)
Product.objects.bulk_update(products, fields=["created_at", "updated_at"])
class Migration(migrations.Migration):
dependencies = [("counter", "0035_remove_selling_is_validated_and_more")]
operations = [
migrations.AddField(
model_name="product",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="created at",
),
preserve_default=False,
),
migrations.AddField(
model_name="product",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
migrations.RunPython(
apply_product_history_dates, reverse_code=migrations.RunPython.noop
),
]

View File

@@ -399,8 +399,6 @@ class Product(models.Model):
Group, related_name="products", verbose_name=_("buying groups"), blank=True
)
archived = models.BooleanField(_("archived"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("product")
@@ -449,7 +447,8 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all())
if not buying_groups:
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
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 {
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 { 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 CounterSchema,
counterSearchCounter,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
productSearchProducts,
producttypeFetchAll,
type SimpleProductSchema,
} from "#openapi";
@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 { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] {
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")
@@ -80,9 +80,9 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
// We need to manually set weights or it results on an inconsistent
// behavior between production and development environment
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 },
// @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 },
],
};

View File

@@ -1,6 +1,6 @@
import { AlertMessage } from "#core:utils/alert-message.ts";
import { BasketItem } from "#counter:counter/basket.ts";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts";
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
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 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 {
type ProductSchema,
type ProductSearchProductsDetailedData,

View File

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

View File

@@ -104,7 +104,7 @@
</div>
<ul>
<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>
<template x-for="error in item.errors">
<div class="alert alert-red" x-text="error">

View File

@@ -3,8 +3,6 @@
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
{% else %}
<h2>{% trans %}Product creation{% endtrans %}</h2>
{% endif %}

View File

@@ -89,7 +89,7 @@
:disabled="csvLoading"
:aria-busy="csvLoading"
>
{% trans %}Download as CSV{% endtrans %} <i class="fa fa-file-arrow-down"></i>
{% trans %}Download as cvs{% endtrans %} <i class="fa fa-file-arrow-down"></i>
</button>
</div>

View File

@@ -43,13 +43,12 @@ def get_eboutic_products(user: User) -> list[Product]:
products = (
get_eboutic()
.products.filter(product_type__isnull=False)
.filter(archived=False, limit_age__lte=user.age)
.annotate(
order=F("product_type__order"),
category=F("product_type__name"),
category_comment=F("product_type__comment"),
price=F("selling_price"), # <-- selected price for basket validation
)
.filter(archived=False)
.filter(limit_age__lte=user.age)
.annotate(order=F("product_type__order"))
.annotate(category=F("product_type__name"))
.annotate(category_comment=F("product_type__comment"))
.annotate(price=F("selling_price")) # <-- selected price for basket validation
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
)
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 (
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
new Date(Number.parseInt(localStorage.basketTimestamp))
) {
this.basket = [];
}

View File

@@ -18,9 +18,7 @@
#basket {
min-width: 300px;
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;
}

View File

@@ -77,14 +77,6 @@
value="{% trans %}Pay with credit card{% endtrans %}"
/>
</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 %}
{% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>

View File

@@ -14,9 +14,9 @@
{% block content %}
<h3 class="election__title">{{ election.title }}</h3>
{% if not user.has_perm("core.view_user") %}
{% if user.is_anonymous %}
<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>
{% endif %}
<p class="election__description">{{ election.description }}</p>

View File

@@ -1,6 +1,7 @@
from datetime import timedelta
import pytest
from pytest_django.asserts import assertRedirects
from django.conf import settings
from django.test import Client, TestCase
from django.urls import reverse
@@ -51,7 +52,137 @@ class TestElectionUpdateView(TestElection):
)
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
def test_election_create_list_permission(client: Client):
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},
"total vote": 100,
},
}
}

View File

@@ -1,6 +1,6 @@
import { default as ForceGraph3D } from "3d-force-graph";
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 SpriteText from "three-spritetext";

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-14 15:21+0100\n"
"POT-Creation-Date: 2026-01-14 11:34+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -388,7 +388,7 @@ msgstr "Montrer"
#: club/templates/club/club_sellings.jinja
#: counter/templates/counter/product_list.jinja
msgid "Download as CSV"
msgid "Download as cvs"
msgstr "Télécharger en CSV"
#: club/templates/club/club_sellings.jinja
@@ -1566,7 +1566,7 @@ msgstr "Visiteur"
msgid "ban type"
msgstr "type de ban"
#: core/models.py counter/models.py
#: core/models.py
msgid "created at"
msgstr "créé le"
@@ -3109,10 +3109,6 @@ msgstr "groupe d'achat"
msgid "archived"
msgstr "archivé"
#: counter/models.py
msgid "updated at"
msgstr "mis à jour le"
#: counter/models.py
msgid "product"
msgstr "produit"
@@ -3668,14 +3664,6 @@ msgstr ""
msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s"
#: counter/templates/counter/product_form.jinja
msgid "Creation date"
msgstr "Date de création"
#: counter/templates/counter/product_form.jinja
msgid "Last update"
msgstr "Dernière mise à jour"
#: counter/templates/counter/product_form.jinja
msgid "Product creation"
msgstr "Création de produit"
@@ -3956,16 +3944,6 @@ msgstr "Solde restant : "
msgid "Pay with credit card"
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
msgid ""
"AE account payment disabled because your basket contains refilling items."
@@ -4131,10 +4109,9 @@ msgid "Candidature are closed for this election"
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."
msgid ""
"You are not logged in, candidate pictures won't display for privacy reasons."
msgstr "Vous n'êtes pas connecté, les photos des candidats ne s'afficheront pas pour des raisons de respect de la vie privée."
#: election/templates/election/election_detail.jinja
msgid "Polls close "

View File

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

2210
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -19,37 +19,37 @@ authors = [
license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12"
dependencies = [
"django>=5.2.11,<6.0.0",
"django-ninja>=1.5.3,<6.0.0",
"django-ninja-extra>=0.31.0",
"Pillow>=12.1.1,<13.0.0",
"mistune>=3.2.0,<4.0.0",
"django>=5.2.8,<6.0.0",
"django-ninja>=1.5.0,<6.0.0",
"django-ninja-extra>=0.30.6",
"Pillow>=12.0.0,<13.0.0",
"mistune>=3.1.4,<4.0.0",
"django-jinja<3.0.0,>=2.11.0",
"cryptography>=46.0.5,<47.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0",
"phonenumbers>=9.0.23,<10.0.0",
"reportlab>=4.4.9,<5.0.0",
"cryptography>=46.0.3,<47.0.0",
"django-phonenumber-field>=8.3.0,<9.0.0",
"phonenumbers>=9.0.18,<10.0.0",
"reportlab>=4.4.4,<5.0.0",
"django-haystack<4.0.0,>=3.3.0",
"xapian-haystack<4.0.0,>=3.1.0",
"libsass<1.0.0,>=0.23.0",
"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",
"sentry-sdk>=2.52.0,<3.0.0",
"sentry-sdk>=2.43.0,<3.0.0",
"jinja2<4.0.0,>=3.1.6",
"django-countries>=8.2.0,<9.0.0",
"dict2xml>=1.7.8,<2.0.0",
"django-countries>=8.0.0,<9.0.0",
"dict2xml>=1.7.7,<2.0.0",
"Sphinx<6,>=5",
"tomli>=2.4.0,<3.0.0",
"tomli>=2.3.0,<3.0.0",
"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",
"redis[hiredis]>=5.3.0,<8.0.0",
"redis[hiredis]<7,>=5.3.0",
"environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0",
"honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7",
"psutil>=7.1.3,<8.0.0",
"celery[redis]>=5.5.2",
"django-celery-results>=2.5.1",
"django-celery-beat>=2.7.0",
]
@@ -60,32 +60,32 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups]
prod = [
"psycopg[c]>=3.3.2,<4.0.0",
"psycopg[c]>=3.2.12,<4.0.0",
]
dev = [
"django-debug-toolbar>=6.2.0,<7",
"ipython>=9.10.0,<10.0.0",
"pre-commit>=4.5.1,<5.0.0",
"ruff>=0.15.0,<1.0.0",
"django-debug-toolbar>=6.1.0,<7",
"ipython>=9.7.0,<10.0.0",
"pre-commit>=4.3.0,<5.0.0",
"ruff>=0.14.4,<1.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",
]
tests = [
"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-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.23.2",
"beautifulsoup4>=4.14.3,<5",
"model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.14.2,<5",
"lxml>=6.0.2,<7",
]
docs = [
"mkdocs<2.0.0,>=1.6.1",
"mkdocs-material>=9.7.1,<10.0.0",
"mkdocstrings>=1.0.3,<2.0.0",
"mkdocstrings-python>=2.0.2,<3.0.0",
"mkdocs-include-markdown-plugin>=7.2.1,<8.0.0",
"mkdocs-material>=9.6.23,<10.0.0",
"mkdocstrings>=0.30.1,<1.0.0",
"mkdocstrings-python>=1.18.2,<2.0.0",
"mkdocs-include-markdown-plugin>=7.2.0,<8.0.0",
]
[tool.uv]
@@ -141,4 +141,4 @@ sith = "sith.pytest"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "sith.settings"
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 { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import {
type AlbumFetchAlbumData,
type AlbumSchema,
albumFetchAlbum,
type PictureSchema,
type PicturesFetchPicturesData,
type PicturesUploadPictureErrors,
albumFetchAlbum,
picturesFetchPictures,
picturesUploadPicture,
} from "#openapi";
@@ -23,7 +23,7 @@ interface SubAlbumsConfig {
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", (config: AlbumPicturesConfig) => ({
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 */,
loading: false,
@@ -38,10 +38,7 @@ document.addEventListener("alpine:init", () => {
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(
new URLSearchParams(window.location.search).get("page"),
10,
) || 1;
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 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 { 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";
@registerComponent("album-ajax-select")

View File

@@ -30,8 +30,8 @@ document.addEventListener("alpine:init", () => {
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
this.downloadPictures.map(async (p: PictureSchema) => {
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), {
level: 9,
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 {
type PictureSchema,
type PicturesFetchPicturesData,
@@ -27,7 +27,7 @@ document.addEventListener("alpine:init", () => {
const lastCachedNumber = localStorage.getItem(localStorageInvalidationKey);
if (
lastCachedNumber !== null &&
Number.parseInt(lastCachedNumber, 10) === config.nbPictures
Number.parseInt(lastCachedNumber) === config.nbPictures
) {
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 { 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 {
type IdentifiedUserSchema,
type ModerationRequestSchema,
type PictureSchema,
type PicturesFetchIdentificationsResponse,
type PicturesFetchPicturesData,
type UserProfileSchema,
picturesDeletePicture,
picturesFetchIdentifications,
picturesFetchModerationRequests,
picturesFetchPictures,
picturesIdentifyUsers,
picturesModeratePicture,
type UserProfileSchema,
usersidentifiedDeleteRelation,
} from "#openapi";
@@ -208,8 +208,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
}
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) =>
i.id === Number.parseInt(event.state.sasPictureId, 10),
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId),
);
});
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
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
await this.currentPicture.loadIdentifications({ forceReload: true });

View File

@@ -8,9 +8,9 @@ document.addEventListener("alpine:init", () => {
async init() {
const userSelect = document.getElementById("id_member") as HTMLSelectElement;
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) {

View File

@@ -1,5 +1,4 @@
import { BarController, BarElement, CategoryScale, Chart, LinearScale } from "chart.js";
Chart.register(BarController, BarElement, CategoryScale, LinearScale);
function getRandomColor() {
@@ -19,11 +18,13 @@ function getRandomColorUniq(list: string[]) {
}
function hexToRgb(hex: string) {
// 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 hexrgb = hex.replace(shorthandRegex, (_m, r, g, 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);
return result
? {

View File

@@ -63,10 +63,10 @@ function parseSlots(s: string): TimetableSlot[] {
}
const [startHour, startMin] = parsed.groups.startHour
.split(":")
.map((i) => Number.parseInt(i, 10));
.map((i) => Number.parseInt(i));
const [endHour, endMin] = parsed.groups.endHour
.split(":")
.map((i) => Number.parseInt(i, 10));
.map((i) => Number.parseInt(i));
return {
...parsed.groups,
startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT),
@@ -157,7 +157,6 @@ document.addEventListener("alpine:init", () => {
.map((c: TimetableSlot) => c.startHour)
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
.split(":")[0],
10,
);
const res: [string, object][] = [];
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 inject from "@rollup/plugin-inject";
import { glob } from "glob";
import { type AliasOptions, type UserConfig, defineConfig } from "vite";
import type { Rollup } from "vite";
import { type AliasOptions, defineConfig, type UserConfig } from "vite";
import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled");