47 Commits

Author SHA1 Message Date
imperosol
9506c8688f show more infos on the formulas list page 2026-02-17 22:08:01 +01:00
imperosol
f3f470ec6c make formula deletion page clearer 2026-02-17 22:08:01 +01:00
imperosol
ced524587f add tests 2026-02-17 22:08:01 +01:00
imperosol
5f01f973de add translations 2026-02-17 22:08:01 +01:00
imperosol
6a6a7e949f add checks on ProductForm 2026-02-17 22:08:01 +01:00
imperosol
4e73f103d8 automatically apply formulas on click 2026-02-17 22:08:01 +01:00
imperosol
b03346c733 product formulas management views 2026-02-17 22:08:01 +01:00
imperosol
7be1d1cc63 feat: ProductFormula model 2026-02-17 22:08:01 +01:00
thomas girod
71ed7cdf7d Merge pull request #1289 from ae-utbm/product_history
Product history
2026-02-17 22:05:59 +01:00
imperosol
43768171a1 show creation date on Product update page 2026-02-17 22:05:34 +01:00
imperosol
0eccb4a5b5 Add created_at and updated_at to Product model 2026-02-17 22:05:19 +01:00
thomas girod
e7584c8c83 Merge pull request #1299 from ae-utbm/populate-more
add ban generation to populate_more
2026-02-17 22:04:18 +01:00
thomas girod
ac06de4f55 Merge pull request #1300 from ae-utbm/csv-typo
fix: typo
2026-02-17 12:30:35 +01:00
imperosol
e2fca3e6d2 fix: typo 2026-02-14 15:22:18 +01:00
imperosol
2138783bde add ban generation to populate_more 2026-02-14 15:14:45 +01:00
thomas girod
4391f63de8 Merge pull request #1292 from ae-utbm/merge-back
Merge back
2026-02-13 15:19:35 +01:00
Sli
8b7eb6edf9 Merge branch 'master' into taiste 2026-02-13 15:16:26 +01:00
f8cda3a31d Merge pull request #1291 from ae-utbm/update-hey-api
Update hey-api
2026-02-13 14:32:18 +01:00
Sli
433d29fcdb Update hey-api 2026-02-13 14:27:52 +01:00
thomas girod
514b8bbec7 Merge pull request #1290 from ae-utbm/update-deps
Update deps
2026-02-13 14:21:15 +01:00
imperosol
84033f37cf update BiomeJS 2026-02-13 14:09:27 +01:00
imperosol
e71f76ea91 remove shorten.min.js 2026-02-13 13:13:40 +01:00
imperosol
530475c4ee update JS dependencies 2026-02-13 11:58:20 +01:00
imperosol
e992bebd68 update python dependencies 2026-02-13 11:52:02 +01:00
thomas girod
8f1c786aa2 Merge pull request #1274 from ae-utbm/remove-club-cache
remove cache calls to fetch user membership
2026-02-11 12:47:06 +01:00
imperosol
c5ae81aae7 update docstrings 2026-02-10 13:08:36 +01:00
imperosol
252acc64c1 remove cache calls to fetch user membership 2026-02-10 13:08:36 +01:00
thomas girod
0d2430a5d4 Merge pull request #1288 from ae-utbm/cb-message
explanation message when eboutic bank payments are disabled
2026-02-09 19:37:12 +01:00
imperosol
b6f77dea97 apply review suggestion 2026-02-09 15:24:52 +01:00
imperosol
df2e65a991 explanation message when eboutic bank payments are disabled 2026-02-08 16:21:09 +01:00
thomas girod
de776045a8 Merge pull request #1287 from ae-utbm/ruff
Update Ruff
2026-02-04 03:10:33 +01:00
imperosol
367ea703ce remove fmt: off 2026-02-03 21:23:34 +01:00
imperosol
bdcb802da8 apply ruff rule PLW0108 2026-02-03 21:12:14 +01:00
imperosol
4e4b5a39f7 update ruff 2026-02-03 21:11:13 +01:00
51534629ed Merge pull request #1279 from ae-utbm/fix_elections
fix: bad value for blank vote and better flow for invalid form
2026-02-03 15:18:33 +01:00
Sli
c042c8e8a3 fix: bad value for blank vote and better flow for invalid form
* Add an error message when looking at a public election without being logged in
* Add correct value for blank vote on single vote field
* Redirect to view with an error message if an invalid form has been submitted
2026-02-03 10:05:36 +01:00
thomas girod
5af894060a Merge pull request #1273 from ae-utbm/fix-counter
fix: wrong quantity displayed on click after removing item
2026-01-21 22:42:27 +01:00
679b8dac1c Merge pull request #1278 from ae-utbm/download-picture-fix
Fix image file generation on user image download
2026-01-21 22:04:09 +01:00
Sli
e9eb3dc17d Fix image file generation on user image download
* Add image id on the name to avoid error with images with the exact same date (if we have epoch for example)
* Fix album name due to schema change not reflected here
2026-01-07 17:49:14 +01:00
imperosol
53a3dc0060 fix: wrong quantity displayed on click after removing item 2025-12-20 06:47:29 +01:00
Titouan
12b098feac Merge pull request #1269 from ae-utbm/dependence_update
update uv dependencies
2025-12-16 10:52:35 +01:00
TitouanDor
0fb86e5d77 modification pyproject.toml 2025-12-15 16:21:22 +01:00
TitouanDor
523e0ff0ee update uv dependencies 2025-12-15 15:22:14 +01:00
thomas girod
0a4d21611e Merge pull request #1255 from ae-utbm/taiste
Refactors, better `PageRev` handling, better user invisibilisation and fixes
2025-11-19 14:02:59 +01:00
thomas girod
992b6d6b79 Merge pull request #1238 from ae-utbm/taiste
Sith theme, `Selling.date` index, galaxy simplification, OG tags, dependencies update, bugfixes and others
2025-11-10 13:19:43 +01:00
Kenneth Soares
710b4aa942 Merge pull request #1213 from ae-utbm/taiste
HTMX, Alpine, Invoice Calls, Products, Bugfixes, Other
2025-10-18 17:29:15 +02:00
Kenneth Soares
5fee2e4720 Merge pull request #1180 from ae-utbm/taiste
Com, Subscriptions, Posters, Others
2025-09-19 21:31:28 +02:00
79 changed files with 2135 additions and 2249 deletions

View File

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

View File

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

View File

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

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 cvs{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
<p><input type="submit" value="{% trans %}Download as CSV{% 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,25 +72,6 @@ class TestMembershipQuerySet(TestClub):
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
@@ -112,24 +93,6 @@ class TestMembershipQuerySet(TestClub):
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
mem_comptable = self.president.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
cache.set(
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
)
# should delete the subscriptions of simple_board_member and president
self.club.members.ongoing().board().delete()
for membership in (mem_skia, mem_comptable):
cached_mem = cache.get(
f"membership_{membership.club_id}_{membership.user_id}"
)
assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)

View File

@@ -1,5 +1,3 @@
import { makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
@@ -8,6 +6,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -95,6 +95,7 @@ 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";
import { exportToHtml } from "#core:utils/globals.ts";
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
from core.models import Group, User, UserBan
from counter.models import (
Counter,
Customer,
@@ -40,6 +40,7 @@ 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)
@@ -88,6 +89,8 @@ 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(
@@ -114,14 +117,33 @@ 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)
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
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
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);
maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed

View File

@@ -1,4 +1,3 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type {
RecursivePartial,
@@ -7,6 +6,7 @@ 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) ?? null;
this.delay = Number.parseInt(newValue, 10) ?? null;
break;
}
case "placeholder": {
@@ -37,11 +37,11 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
this.max = Number.parseInt(newValue, 10) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
this.minCharNumberForSearch = Number.parseInt(newValue, 10) ?? 0;
break;
}
default: {

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
@registerComponent("nfc-input")
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";
import { exportToHtml } from "#core:utils/globals.ts";
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),
Number.parseInt(element.getAttribute("show-more") as string, 10),
{
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 @@
import { exportToHtml } from "#core:utils/globals";
// biome-ignore lint/style/noNamespaceImport: this is the recommended way from the documentation
// biome-ignore lint/performance/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,7 +8,6 @@
// 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;
@@ -28,7 +27,6 @@ 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;
@@ -74,7 +72,6 @@ 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,4 +1,3 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
@@ -6,7 +5,8 @@ import cytoscape, {
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
@@ -200,7 +200,7 @@ document.addEventListener("alpine:init", () => {
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop));
const value = Number.parseInt(initialUrlParams.get(prop), 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
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";
import type { NestedKeyOf } from "#core:utils/types.ts";
interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */

View File

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

View File

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

View File

@@ -21,6 +21,8 @@
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %}
<p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
{% if help_text %}<p><em>{{ help_text }}</em></p>{% endif %}
<br/>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form>
<form method="GET" action="javascript:history.back();">

View File

@@ -22,19 +22,17 @@ 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, Membership
from club.models import Club
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo
@@ -436,23 +434,6 @@ 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),
(lambda: subscriber_user.make(), 403),
(lambda: old_subscriber_user.make(), 403),
(lambda: board_user.make(), 403),
(subscriber_user.make, 403),
(old_subscriber_user.make, 403),
(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_receipe", "file", "expected_status"),
("user_recipe", "file", "expected_status"),
[
(
lambda: None,
@@ -279,21 +279,21 @@ def test_apply_rights_recursively():
403,
),
(
lambda: subscriber_user.make(),
subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
lambda: old_subscriber_user.make(),
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
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=b"invalid", content_type="image/jpg"
),
422,
),
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
),
200, # PIL can guess
),
(
lambda: old_subscriber_user.make(),
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_receipe: Callable[[], User | None],
user_recipe: Callable[[], User | None],
file: UploadedFile | None,
expected_status: int,
):
if (user := user_receipe()) is not None:
if (user := user_recipe()) 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,9 +418,7 @@ 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), lambda: AnonymousUser()]
)
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), 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,8 +39,9 @@ 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

@@ -5,6 +5,7 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.timezone import now
@@ -34,6 +35,7 @@ from counter.models import (
Eticket,
InvoiceCall,
Product,
ProductFormula,
Refilling,
ReturnableProduct,
ScheduledProductAction,
@@ -316,7 +318,6 @@ class ProductForm(forms.ModelForm):
}
counters = forms.ModelMultipleChoiceField(
help_text=None,
label=_("Counters"),
required=False,
widget=AutoCompleteSelectMultipleCounter,
@@ -327,10 +328,31 @@ class ProductForm(forms.ModelForm):
super().__init__(*args, instance=instance, **kwargs)
if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all()
if hasattr(self.instance, "formula"):
self.formula_init(self.instance.formula)
self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs
)
def formula_init(self, formula: ProductFormula):
"""Part of the form initialisation specific to formula products."""
self.fields["selling_price"].help_text = _(
"This product is a formula. "
"Its price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_selling_price}
self.fields["special_selling_price"].help_text = _(
"This product is a formula. "
"Its special price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_special_selling_price}
for key, price in (
("selling_price", formula.max_selling_price),
("special_selling_price", formula.max_special_selling_price),
):
self.fields[key].widget.attrs["max"] = price
self.fields[key].validators.append(MaxValueValidator(price))
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
@@ -349,13 +371,47 @@ class ProductForm(forms.ModelForm):
return product
class ProductFormulaForm(forms.ModelForm):
class Meta:
model = ProductFormula
fields = ["products", "result"]
widgets = {
"products": AutoCompleteSelectMultipleProduct,
"result": AutoCompleteSelectProduct,
}
def clean(self):
cleaned_data = super().clean()
if cleaned_data["result"] in cleaned_data["products"]:
self.add_error(
None,
_(
"The same product cannot be at the same time "
"the result and a part of the formula."
),
)
prices = [p.selling_price for p in cleaned_data["products"]]
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
selling_price = cleaned_data["result"].selling_price
special_selling_price = cleaned_data["result"].special_selling_price
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
self.add_error(
"result",
_(
"The result cannot be more expensive "
"than the total of the other products."
),
)
return cleaned_data
class ReturnableProductForm(forms.ModelForm):
class Meta:
model = ReturnableProduct
fields = ["product", "returned_product", "max_return"]
widgets = {
"product": AutoCompleteSelectProduct(),
"returned_product": AutoCompleteSelectProduct(),
"product": AutoCompleteSelectProduct,
"returned_product": AutoCompleteSelectProduct,
}
def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT

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)
) # fmt: off
# cf. https://github.com/astral-sh/ruff/issues/14103
AccountDump.objects.ongoing().filter(
customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold
)
)
return (
User.objects.filter(Exists(ongoing_dump_operations))
.annotate(

View File

@@ -0,0 +1,67 @@
# 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

@@ -0,0 +1,43 @@
# Generated by Django 5.2.8 on 2025-11-26 11:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0036_product_created_at_product_updated_at")]
operations = [
migrations.CreateModel(
name="ProductFormula",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"products",
models.ManyToManyField(
help_text="The products that constitute this formula.",
related_name="formulas",
to="counter.product",
verbose_name="products",
),
),
(
"result",
models.OneToOneField(
help_text="The formula product.",
on_delete=django.db.models.deletion.CASCADE,
to="counter.product",
verbose_name="result product",
),
),
],
),
]

View File

@@ -399,6 +399,8 @@ 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")
@@ -447,14 +449,44 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
res = any(user.is_in_group(pk=group.id) for group in buying_groups)
return res
return any(user.is_in_group(pk=group.id) for group in buying_groups)
@property
def profit(self):
return self.selling_price - self.purchase_price
class ProductFormula(models.Model):
products = models.ManyToManyField(
Product,
related_name="formulas",
verbose_name=_("products"),
help_text=_("The products that constitute this formula."),
)
result = models.OneToOneField(
Product,
related_name="formula",
on_delete=models.CASCADE,
verbose_name=_("result product"),
help_text=_("The product got with the formula."),
)
def __str__(self):
return self.result.name
@cached_property
def max_selling_price(self) -> float:
# iterating over all products is less efficient than doing
# a simple aggregation, but this method is likely to be used in
# coordination with `max_special_selling_price`,
# and Django caches the result of the `all` queryset.
return sum(p.selling_price for p in self.products.all())
@cached_property
def max_special_selling_price(self) -> float:
return sum(p.special_selling_price for p in self.products.all())
class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self:
"""Annotate the queryset with the `user_is_barman` field.

View File

@@ -1,4 +1,4 @@
import type { Product } from "#counter:counter/types";
import type { Product } from "#counter:counter/types.ts";
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,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
type ProductTypeSchema,
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"), parsed[2]];
return [Number.parseInt(parsed[1] || "1", 10), 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-ignore documentation says it's fine, specified type is wrong
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 },
// @ts-ignore documentation says it's fine, specified type is wrong
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 },
],
};

View File

@@ -1,6 +1,10 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import { AlertMessage } from "#core:utils/alert-message.ts";
import { BasketItem } from "#counter:counter/basket.ts";
import type {
CounterConfig,
ErrorMessage,
ProductFormula,
} from "#counter:counter/types.ts";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
document.addEventListener("alpine:init", () => {
@@ -47,15 +51,43 @@ document.addEventListener("alpine:init", () => {
this.basket[id] = item;
this.checkFormulas();
if (this.sumBasket() > this.customerBalance) {
item.quantity = oldQty;
if (item.quantity === 0) {
delete this.basket[id];
}
return gettext("Not enough money");
this.alertMessage.display(gettext("Not enough money"), { success: false });
}
},
return "";
checkFormulas() {
const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i)),
);
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p));
});
if (formula === undefined) {
return;
}
for (const product of formula.products) {
const key = product.toString();
this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key);
}
}
this.alertMessage.display(
interpolate(
gettext("Formula %(formula)s applied"),
{ formula: config.products[formula.result.toString()].name },
true,
),
{ success: true },
);
this.addToBasket(formula.result.toString(), 1);
},
getBasketSize() {
@@ -70,14 +102,7 @@ document.addEventListener("alpine:init", () => {
(acc: number, cur: BasketItem) => acc + cur.sum(),
0,
) as number;
return total;
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.alertMessage.display(message, { success: false });
}
return Math.round(total * 100) / 100;
},
onRefillingSuccess(event: CustomEvent) {
@@ -116,7 +141,7 @@ document.addEventListener("alpine:init", () => {
this.finish();
}
} else {
this.addToBasketWithMessage(code, quantity);
this.addToBasket(code, quantity);
}
this.codeField.widget.clear();
this.codeField.widget.focus();

View File

@@ -1,9 +1,13 @@
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import { showSaveFilePicker } from "native-file-system-adapter";
import 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"));
Number.parseInt(elem.getAttribute("x-sort:item"), 10);
const query =
newPosition === 0
? { above: getId(productTypes.item(1)) }

View File

@@ -7,10 +7,16 @@ export interface InitialFormData {
errors?: string[];
}
export interface ProductFormula {
result: number;
products: number[];
}
export interface CounterConfig {
customerBalance: number;
customerId: number;
products: Record<string, Product>;
formulas: ProductFormula[];
formInitial: InitialFormData[];
cancelUrl: string;
}

View File

@@ -10,12 +10,12 @@
float: right;
}
.basket-error-container {
.basket-message-container {
position: relative;
display: block
}
.basket-error {
.basket-message {
z-index: 10; // to get on top of tomselect
text-align: center;
position: absolute;

View File

@@ -32,13 +32,11 @@
<div id="bar-ui" x-data="counter({
customerBalance: {{ customer.amount }},
products: products,
formulas: formulas,
customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: '{{ cancel_url }}',
})">
<noscript>
<p class="important">Javascript is required for the counter UI.</p>
</noscript>
<div id="user_info">
<h5>{% trans %}Customer{% endtrans %}</h5>
@@ -88,11 +86,12 @@
<form x-cloak method="post" action="" x-ref="basketForm">
<div class="basket-error-container">
<div class="basket-message-container">
<div
x-cloak
class="alert alert-red basket-error"
x-show="alertMessage.show"
class="alert basket-message"
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
x-transition.duration.500ms
x-text="alertMessage.content"
></div>
@@ -104,16 +103,16 @@
</div>
<ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)">
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
<li>
<template x-for="error in item.errors">
<div class="alert alert-red" x-text="error">
</div>
</template>
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
<button @click.prevent="addToBasket(item.product.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
<button @click.prevent="addToBasket(item.product.id, 1)">+</button>
<span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
@@ -213,7 +212,7 @@
<h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x">
{% for product in categories[category] -%}
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
<button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
<img
class="card-image"
alt="image de {{ product.name }}"
@@ -252,6 +251,18 @@
},
{%- endfor -%}
};
const formulas = [
{%- for formula in formulas -%}
{
result: {{ formula.result_id }},
products: [
{%- for product in formula.products.all() -%}
{{ product.id }},
{%- endfor -%}
]
},
{%- endfor -%}
];
const formInitial = [
{%- for f in form -%}
{%- if f.cleaned_data -%}

View File

@@ -0,0 +1,78 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product formulas{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
{% endblock %}
{% block content %}
<main>
<h3 class="margin-bottom">{% trans %}Product formulas{% endtrans %}</h3>
<p>
{%- trans trimmed -%}
Formulas allow you to associate a group of products
with a result product (the formula itself).
{%- endtrans -%}
</p>
<p>
{%- trans trimmed -%}
If the product of a formula is available on a counter,
it will be automatically applied if all the products that
make it up are added to the basket.
{%- endtrans -%}
</p>
<p>
{%- trans trimmed -%}
For example, if there is a formula that combines a "Sandwich Formula" product
with the "Sandwich" and "Soft Drink" products,
then, if a person orders a sandwich and a soft drink,
the formula will be applied and the basket will then contain a sandwich formula instead.
{%- endtrans -%}
</p>
<p class="margin-bottom">
<a href="{{ url('counter:product_formula_create') }}" class="btn btn-blue">
{% trans %}New formula{% endtrans %}
<i class="fa fa-plus"></i>
</a>
</p>
<div class="product-group">
{%- for formula in object_list -%}
<a
class="card card-row shadow clickable"
href="{{ url('counter:product_formula_edit', formula_id=formula.id) }}"
>
<div class="card-content">
<strong class="card-title">{{ formula.result.name }}</strong>
<p>
{% for p in formula.products.all() %}
<i>{{ p.code }} ({{ p.selling_price }} €)</i>
{% if not loop.last %}+{% endif %}
{% endfor %}
</p>
<p>
{{ formula.result.selling_price }}
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
</p>
</div>
{% if user.has_perm("counter.delete_productformula") %}
<button
x-data
class="btn btn-red btn-no-text card-top-left"
@click.prevent="document.location.href = '{{ url('counter:product_formula_delete', formula_id=formula.id) }}'"
>
{# The delete link is a button with a JS event listener
instead of a proper <a> element,
because the enclosing card is already a <a>,
and HTML forbids nested <a> #}
<i class="fa fa-trash"></i>
</button>
{% endif %}
</a>
{%- endfor -%}
</div>
</main>
{% endblock %}

View File

@@ -3,6 +3,8 @@
{% 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 cvs{% endtrans %} <i class="fa fa-file-arrow-down"></i>
{% trans %}Download as CSV{% endtrans %} <i class="fa fa-file-arrow-down"></i>
</button>
</div>

View File

@@ -0,0 +1,59 @@
from django.test import TestCase
from counter.baker_recipes import product_recipe
from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
def test_ok(self):
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert form.is_valid()
formula = form.save()
assert formula.result == self.products[0]
assert set(formula.products.all()) == set(self.products[1:])
def test_price_invalid(self):
self.products[0].selling_price = 2.1
self.products[0].save()
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert not form.is_valid()
assert form.errors == {
"result": [
"Le résultat ne peut pas être plus cher "
"que le total des autres produits."
]
}
def test_product_both_in_result_and_products(self):
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[0].id, self.products[1].id],
}
)
assert not form.is_valid()
assert form.errors == {
"__all__": [
"Un même produit ne peut pas être à la fois "
"le résultat et un élément de la formule."
]
}

View File

@@ -15,8 +15,9 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.forms import ProductForm
from counter.models import Product, ProductType
from counter.models import Product, ProductFormula, ProductType
@pytest.mark.django_db
@@ -93,6 +94,9 @@ class TestCreateProduct(TestCase):
def setUpTestData(cls):
cls.product_type = baker.make(ProductType)
cls.club = baker.make(Club)
cls.counter_admin = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
cls.data = {
"name": "foo",
"description": "bar",
@@ -116,13 +120,36 @@ class TestCreateProduct(TestCase):
assert instance.name == "foo"
assert instance.selling_price == 1.0
def test_view(self):
self.client.force_login(
baker.make(
User,
groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
)
def test_form_with_product_from_formula(self):
"""Test when the edited product is a result of a formula."""
self.client.force_login(self.counter_admin)
products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
baker.make(ProductFormula, result=products[0], products=products[1:])
data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5}
form = ProductForm(data=data, instance=products[0])
assert form.is_valid()
# it shouldn't be possible to give a price higher than the formula's products
data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9}
form = ProductForm(data=data, instance=products[0])
assert not form.is_valid()
assert form.errors == {
"selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 2.00."
],
"special_selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 1.80."
],
}
def test_view(self):
self.client.force_login(self.counter_admin)
url = reverse("counter:new_product")
response = self.client.get(url)
assert response.status_code == 200

View File

@@ -25,6 +25,10 @@ from counter.views.admin import (
CounterStatView,
ProductCreateView,
ProductEditView,
ProductFormulaCreateView,
ProductFormulaDeleteView,
ProductFormulaEditView,
ProductFormulaListView,
ProductListView,
ProductTypeCreateView,
ProductTypeEditView,
@@ -116,6 +120,24 @@ urlpatterns = [
ProductEditView.as_view(),
name="product_edit",
),
path(
"admin/formula/", ProductFormulaListView.as_view(), name="product_formula_list"
),
path(
"admin/formula/new/",
ProductFormulaCreateView.as_view(),
name="product_formula_create",
),
path(
"admin/formula/<int:formula_id>/edit",
ProductFormulaEditView.as_view(),
name="product_formula_edit",
),
path(
"admin/formula/<int:formula_id>/delete",
ProductFormulaDeleteView.as_view(),
name="product_formula_delete",
),
path(
"admin/product-type/list/",
ProductTypeListView.as_view(),

View File

@@ -34,11 +34,13 @@ from counter.forms import (
CloseCustomerAccountForm,
CounterEditForm,
ProductForm,
ProductFormulaForm,
ReturnableProductForm,
)
from counter.models import (
Counter,
Product,
ProductFormula,
ProductType,
Refilling,
ReturnableProduct,
@@ -162,6 +164,62 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products"
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
model = ProductFormula
queryset = ProductFormula.objects.select_related("result").prefetch_related(
"products"
)
template_name = "counter/formula_list.jinja"
current_tab = "formulas"
permission_required = "counter.view_productformula"
class ProductFormulaCreateView(
CounterAdminTabsMixin, PermissionRequiredMixin, CreateView
):
model = ProductFormula
form_class = ProductFormulaForm
pk_url_kwarg = "formula_id"
template_name = "core/create.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.add_productformula"
class ProductFormulaEditView(
CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
):
model = ProductFormula
form_class = ProductFormulaForm
pk_url_kwarg = "formula_id"
template_name = "core/edit.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.change_productformula"
class ProductFormulaDeleteView(
CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView
):
model = ProductFormula
pk_url_kwarg = "formula_id"
template_name = "core/delete_confirm.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.delete_productformula"
def get_context_data(self, **kwargs):
obj_name = self.object.result.name
return super().get_context_data(**kwargs) | {
"object_name": _("%(formula)s (formula)") % {"formula": obj_name},
"help_text": _(
"This action will only delete the formula, "
"but not the %(product)s product itself."
)
% {"product": obj_name},
}
class ReturnableProductListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):

View File

@@ -12,6 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from collections import defaultdict
from django.core.exceptions import PermissionDenied
from django.db import transaction
@@ -31,6 +32,7 @@ from counter.forms import BasketForm, RefillForm
from counter.models import (
Counter,
Customer,
ProductFormula,
ReturnableProduct,
Selling,
)
@@ -206,12 +208,13 @@ class CounterClick(
"""Add customer to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products
kwargs["categories"] = {}
kwargs["formulas"] = ProductFormula.objects.filter(
result__in=self.products
).prefetch_related("products")
kwargs["categories"] = defaultdict(list)
for product in kwargs["products"]:
if product.product_type:
kwargs["categories"].setdefault(product.product_type, []).append(
product
)
kwargs["categories"][product.product_type].append(product)
kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url()

View File

@@ -100,6 +100,11 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "products",
"name": _("Products"),
},
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
},
{
"url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",

View File

@@ -43,12 +43,13 @@ def get_eboutic_products(user: User) -> list[Product]:
products = (
get_eboutic()
.products.filter(product_type__isnull=False)
.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
.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
)
.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))
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
this.basket = [];
}

View File

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

View File

@@ -1,7 +1,6 @@
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
@@ -52,137 +51,7 @@ 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))
@@ -245,4 +114,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/style/noNamespaceImport: This is how it should be imported
// biome-ignore lint/performance/noNamespaceImport: This is how it should be imported
import * as Three from "three";
import SpriteText from "three-spritetext";

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-14 11:34+0100\n"
"POT-Creation-Date: 2026-02-14 15:21+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 cvs"
msgid "Download as CSV"
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
#: core/models.py counter/models.py
msgid "created at"
msgstr "créé le"
@@ -2957,6 +2957,38 @@ msgstr ""
"Décrivez le produit. Si c'est un click pour un évènement, donnez quelques "
"détails dessus, comme la date (en incluant l'année)."
#: counter/forms.py
#, python-format
msgid ""
"This product is a formula. Its price cannot be greater than the price of the "
"products constituting it, which is %(price)s"
msgstr ""
"Ce produit est une formule. Son prix ne peut pas être supérieur au prix des "
"produits qui la constituent, soit %(price)s €."
#: counter/forms.py
#, python-format
msgid ""
"This product is a formula. Its special price cannot be greater than the "
"price of the products constituting it, which is %(price)s"
msgstr ""
"Ce produit est une formule. Son prix spécial ne peut pas être supérieur au "
"prix des produits qui la constituent, soit %(price)s €."
#: counter/forms.py
msgid ""
"The same product cannot be at the same time the result and a part of the "
"formula."
msgstr ""
"Un même produit ne peut pas être à la fois le résultat et un élément de la "
"formule."
#: counter/forms.py
msgid ""
"The result cannot be more expensive than the total of the other products."
msgstr ""
"Le résultat ne peut pas être plus cher que le total des autres produits."
#: counter/forms.py
msgid "Refound this account"
msgstr "Rembourser ce compte"
@@ -3109,6 +3141,10 @@ 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"
@@ -3117,6 +3153,18 @@ msgstr "produit"
msgid "products"
msgstr "produits"
#: counter/models.py
msgid "The products that constitute this formula."
msgstr "Les produits qui constituent cette formule."
#: counter/models.py
msgid "result product"
msgstr "produit résultat"
#: counter/models.py
msgid "The product got with the formula."
msgstr "Le produit obtenu par la formule."
#: counter/models.py
msgid "counter type"
msgstr "type de comptoir"
@@ -3537,6 +3585,48 @@ msgstr "Nouveau eticket"
msgid "There is no eticket in this website."
msgstr "Il n'y a pas de eticket sur ce site web."
#: counter/templates/counter/formula_list.jinja
msgid "Product formulas"
msgstr "Formules de produits"
#: counter/templates/counter/formula_list.jinja
msgid ""
"Formulas allow you to associate a group of products with a result product "
"(the formula itself)."
msgstr ""
"Les formules permettent d'associer un groupe de produits à un produit "
"résultat (la formule en elle-même)."
#: counter/templates/counter/formula_list.jinja
msgid ""
"If the product of a formula is available on a counter, it will be "
"automatically applied if all the products that make it up are added to the "
"basket."
msgstr ""
"Si le produit d'une formule est disponible sur un comptoir, celle-ci sera "
"automatiquement appliquée si tous les produits qui la constituent sont "
"ajoutés au panier."
#: counter/templates/counter/formula_list.jinja
msgid ""
"For example, if there is a formula that combines a \"Sandwich Formula\" "
"product with the \"Sandwich\" and \"Soft Drink\" products, then, if a person "
"orders a sandwich and a soft drink, the formula will be applied and the "
"basket will then contain a sandwich formula instead."
msgstr ""
"Par exemple s'il existe une formule associant un produit « Formule "
"sandwich » aux produits « Sandwich » et « Soft », alors, si une personne "
"commande un sandwich et un soft, la formule sera appliquée et le panier "
"contiendra alors une formule sandwich à la place."
#: counter/templates/counter/formula_list.jinja
msgid "New formula"
msgstr "Nouvelle formule"
#: counter/templates/counter/formula_list.jinja
msgid "instead of"
msgstr "au lieu de"
#: counter/templates/counter/fragments/create_student_card.jinja
msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée."
@@ -3664,6 +3754,14 @@ 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"
@@ -3791,6 +3889,20 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
#, python-format
msgid "%(formula)s (formula)"
msgstr "%(formula)s (formule)"
#: counter/views/admin.py
#, python-format
msgid ""
"This action will only delete the formula, but not the %(product)s product "
"itself."
msgstr ""
"Cette action supprimera seulement la formule, mais pas le produit "
"%(product)s en lui-même."
#: counter/views/admin.py
#, python-format
msgid "returnable product : %(returnable)s -> %(returned)s"
@@ -3876,6 +3988,10 @@ msgstr "Dernières opérations"
msgid "Counter administration"
msgstr "Administration des comptoirs"
#: counter/views/mixins.py
msgid "Formulas"
msgstr "Formules"
#: counter/views/mixins.py
msgid "Product types"
msgstr "Types de produit"
@@ -3944,6 +4060,16 @@ 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."
@@ -4109,9 +4235,10 @@ msgid "Candidature are closed for this election"
msgstr "Les candidatures sont fermées pour cette élection"
#: election/templates/election/election_detail.jinja
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."
msgid "Candidate pictures won't display for privacy reasons."
msgstr ""
"La photo du candidat ne s'affiche pas pour des raisons de respect de la vie "
"privée."
#: election/templates/election/election_detail.jinja
msgid "Polls close "

View File

@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-08-23 15:30+0200\n"
"POT-Creation-Date: 2025-11-26 15:45+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -206,6 +206,10 @@ msgstr "capture.%s"
msgid "Not enough money"
msgstr "Pas assez d'argent"
#: counter/static/bundled/counter/counter-click-index.ts
msgid "Formula %(formula)s applied"
msgstr "Formule %(formula)s appliquée"
#: counter/static/bundled/counter/counter-click-index.ts
msgid "You can't send an empty basket."
msgstr "Vous ne pouvez pas envoyer un panier vide."
@@ -262,3 +266,9 @@ msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image"
#: timetable/static/bundled/timetable/generator-index.ts
msgid ""
"Wrong timetable format. Make sure you copied if from your student folder."
msgstr ""
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants."

View File

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

2210
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,9 @@
"keywords": [],
"author": "",
"license": "GPL-3.0-only",
"sideEffects": [".css"],
"sideEffects": [
".css"
],
"imports": {
"#openapi": "./staticfiles/generated/openapi/client/index.ts",
"#core:*": "./core/static/bundled/*",
@@ -24,33 +26,33 @@
"#com:*": "./com/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@biomejs/biome": "^1.9.4",
"@hey-api/openapi-ts": "^0.73.0",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@biomejs/biome": "^2.3.14",
"@hey-api/openapi-ts": "^0.92.4",
"@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": "^6.4.1",
"vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.4"
"vite-plugin-static-copy": "^3.2.0"
},
"dependencies": {
"@alpinejs/sort": "^3.15.1",
"@alpinejs/sort": "^3.15.8",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@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",
"@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",
"chart.js": "^4.5.1",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.33.1",
@@ -58,14 +60,14 @@
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
"easymde": "^2.20.0",
"glob": "^11.0.3",
"glob": "^13.0.2",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.8",
"js-cookie": "^3.0.5",
"lit-html": "^3.3.1",
"lit-html": "^3.3.2",
"native-file-system-adapter": "^3.0.1",
"three": "^0.177.0",
"three": "^0.182.0",
"three-spritetext": "^1.10.0",
"tom-select": "^2.4.3"
"tom-select": "^2.5.1"
}
}

View File

@@ -1,4 +1,8 @@
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import { ueFetchUeList } from "#openapi";
const pageDefault = 1;
@@ -31,8 +35,8 @@ document.addEventListener("alpine:init", () => {
const url = getCurrentUrlParams();
this.pushstate = History.Replace;
this.page = Number.parseInt(url.get("page")) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size")) || pageSizeDefault;
this.page = Number.parseInt(url.get("page"), 10) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size"), 10) || 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.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>=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-jinja<3.0.0,>=2.11.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",
"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",
"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.2",
"django-simple-captcha<1.0.0,>=0.6.3",
"python-dateutil<3.0.0.0,>=2.9.0.post0",
"sentry-sdk>=2.43.0,<3.0.0",
"sentry-sdk>=2.52.0,<3.0.0",
"jinja2<4.0.0,>=3.1.6",
"django-countries>=8.0.0,<9.0.0",
"dict2xml>=1.7.7,<2.0.0",
"django-countries>=8.2.0,<9.0.0",
"dict2xml>=1.7.8,<2.0.0",
"Sphinx<6,>=5",
"tomli>=2.3.0,<3.0.0",
"tomli>=2.4.0,<3.0.0",
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0",
"pydantic-extra-types>=2.11.0,<3.0.0",
"ical>=11.1.0,<12",
"redis[hiredis]<7,>=5.3.0",
"redis[hiredis]>=5.3.0,<8.0.0",
"environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0",
"honcho>=2.0.0",
"psutil>=7.1.3,<8.0.0",
"celery[redis]>=5.5.2",
"psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7",
"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.2.12,<4.0.0",
"psycopg[c]>=3.3.2,<4.0.0",
]
dev = [
"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",
"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",
"djhtml>=3.0.10,<4.0.0",
"faker>=37.12.0,<38.0.0",
"faker>=40.4.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0",
]
tests = [
"freezegun>=1.5.5,<2.0.0",
"pytest>=8.4.2,<9.0.0",
"pytest>=9.0.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0",
"pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.14.2,<5",
"model-bakery<2.0.0,>=1.23.2",
"beautifulsoup4>=4.14.3,<5",
"lxml>=6.0.2,<7",
]
docs = [
"mkdocs<2.0.0,>=1.6.1",
"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",
"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",
]
[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";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { paginated } from "#core:utils/api.ts";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
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")) || 1,
page: Number.parseInt(initialUrlParams.get("page"), 10) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false,
@@ -38,7 +38,10 @@ document.addEventListener("alpine:init", () => {
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
Number.parseInt(
new URLSearchParams(window.location.search).get("page"),
10,
) || 1;
});
},

View File

@@ -1,7 +1,7 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { 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(async (p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),

View File

@@ -1,4 +1,4 @@
import { paginated } from "#core:utils/api";
import { paginated } from "#core:utils/api.ts";
import {
type PictureSchema,
type PicturesFetchPicturesData,
@@ -27,7 +27,7 @@ document.addEventListener("alpine:init", () => {
const lastCachedNumber = localStorage.getItem(localStorageInvalidationKey);
if (
lastCachedNumber !== null &&
Number.parseInt(lastCachedNumber) === config.nbPictures
Number.parseInt(lastCachedNumber, 10) === 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,7 +208,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
}
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId),
(i: PictureSchema) =>
i.id === Number.parseInt(event.state.sasPictureId, 10),
);
});
this.pushstate = History.Replace; /* Avoid first url push */
@@ -301,7 +302,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)),
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// 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));
await this.loadProfile(Number.parseInt(userSelect.value, 10));
});
await this.loadProfile(Number.parseInt(userSelect.value));
await this.loadProfile(Number.parseInt(userSelect.value, 10));
},
async loadProfile(userId: number) {

View File

@@ -1,4 +1,5 @@
import { BarController, BarElement, CategoryScale, Chart, LinearScale } from "chart.js";
Chart.register(BarController, BarElement, CategoryScale, LinearScale);
function getRandomColor() {
@@ -18,13 +19,11 @@ 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));
.map((i) => Number.parseInt(i, 10));
const [endHour, endMin] = parsed.groups.endHour
.split(":")
.map((i) => Number.parseInt(i));
.map((i) => Number.parseInt(i, 10));
return {
...parsed.groups,
startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT),
@@ -157,6 +157,7 @@ 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");