Compare commits

...
147 Commits
Author SHA1 Message Date
klmp200 35a55ff95a Fix animation bug on club list
Also, due to some weird jinja bug, css block is applied even if supposedly
in a conditional statement
2026-09-08 23:17:35 +02:00
klmp200 00cae33a33 Fix moderation view 2026-09-07 17:36:53 +02:00
klmp200 4bfcb6a267 Fix club-list pagination 2026-09-07 17:36:50 +02:00
thomas girod 4f885b4ba7 Merge pull request #1479 from ae-utbm/bump-python
python 3.14
2026-09-07 17:03:57 +02:00
imperosol 13ee70afe9 update python deps 2026-09-07 16:16:55 +02:00
imperosol 7bd79d1d55 re-apply ruff TCH rules 2026-09-07 13:32:13 +02:00
imperosol d93afffbc3 python 3.14 2026-09-07 11:48:02 +02:00
thomas girod d2c259c4f9 Merge pull request #1474 from ae-utbm/404-msg
funny 404 message
2026-09-04 16:12:37 +02:00
thomas girod f57fe290d6 Merge pull request #1475 from ae-utbm/fix-timetable-regex
fix: timetable
2026-09-04 15:55:37 +02:00
imperosol e7b5330f4f make timetable generator available to everyone 2026-09-04 15:50:21 +02:00
imperosol 3cdbb9605c fix: timetable regex 2026-09-04 15:47:46 +02:00
imperosol c17f2feefc funny 404 message 2026-09-04 11:25:06 +02:00
thomas girod 22ecd01898 Merge pull request #1473 from ae-utbm/js-markdown
fix: use good version of aemark
2026-09-03 16:31:48 +02:00
imperosol 1fa4ccd710 fix: use good version of aemark 2026-09-03 16:30:05 +02:00
thomas girod 2a71708ccd Merge pull request #1472 from ae-utbm/js-markdown
Js markdown
2026-09-03 16:22:43 +02:00
imperosol 549d8c67e7 doc: ae markdown 2026-09-03 16:10:18 +02:00
imperosol ff69741c82 remove /api/markdown route 2026-09-03 12:03:52 +02:00
imperosol b5b837498f Use client-side md parser for easymde 2026-09-03 11:50:55 +02:00
imperosol 8ef3054888 add @ae_utbm/aemark to JS deps 2026-09-03 11:50:55 +02:00
thomas girod 362f1a6a62 Merge pull request #1470 from ae-utbm/subscription-creation-date
add created_at column to Subscription
2026-09-01 23:35:33 +02:00
imperosol b6347829c6 add translations 2026-09-01 18:34:03 +02:00
imperosol 2099e1bd36 add created_at column to Subscription 2026-09-01 15:45:51 +02:00
thomas girod 7fca5d8c75 Merge pull request #1468 from ae-utbm/fix-duplicate-user-search
fix: duplicate user search when a whitelist exists
2026-08-31 18:03:19 +02:00
imperosol 3467aad846 fix: duplicate user search when a whitelist exists
Quand un utilisateur possède une whitelist d'utilisateurs, qu'il effectue une recherche et qu'il apparait dans les résultats, son profil apparait plusieurs fois.
2026-08-31 16:52:42 +02:00
klmp200 ebfac638de Merge pull request #1465 from ae-utbm/hotfix
Fix broken product widget
2026-08-30 22:40:08 +02:00
klmp200 839536661b Merge pull request #1466 from ae-utbm/htmx4
Migrate to HTMX4
2026-08-30 22:39:52 +02:00
thomas girod b1b3639dc9 Merge pull request #1467 from ae-utbm/pedagogy-style
Pedagogy style
2026-08-30 22:38:34 +02:00
klmp200 56c832ba06 Migrate to HTMX4 2026-08-30 22:07:53 +02:00
imperosol 884019d803 style: standardize UE search style 2026-08-30 21:53:00 +02:00
klmp200 dfc714ff3d Fix broken ProductAjaxSelect widget 2026-08-28 15:05:17 +02:00
thomas girod 7808ca3688 Merge pull request #1464 from ae-utbm/tsc6
Tsc6
2026-08-25 22:28:21 +02:00
imperosol 7e42ad138c fix: deprecation warnings in vite config 2026-08-25 22:23:37 +02:00
imperosol 3c4e7defb8 fix: build error with HeyAPI because of TS7 2026-08-25 21:40:21 +02:00
thomas girod 2fab0ae96d Merge pull request #1461 from ae-utbm/update-deps
update deps
2026-08-24 12:49:15 +02:00
imperosol 68c5231138 update JS deps 2026-08-23 19:41:43 +02:00
klmp200 17708e19c5 Merge pull request #1460 from ae-utbm/pedagogy
Fix star width and rewrite star widget with nice hovering
2026-08-23 18:00:45 +02:00
imperosol e1be017877 update python deps 2026-08-23 17:14:46 +02:00
klmp200 4a8471983f Apply review comments 2026-08-23 15:17:03 +02:00
thomas girod c9523b0730 Merge pull request #1449 from ae-utbm/ts
Typescript strict mode
2026-08-22 11:38:31 +02:00
klmp200 82e71263dd StarList uses proper inheritance and properly passes initial values and attributes + add empty comment clause 2026-08-21 14:22:46 +02:00
klmp200 12135b5c22 Fix star width and rewrite star widget with nice hovering 2026-08-21 00:14:04 +02:00
imperosol 58c2406ac5 fix: Uncaught RangeError: Invalid time value is SAS 2026-08-20 16:18:06 +02:00
imperosol b8178e3534 update doc in js-import-paths.md 2026-08-20 16:17:23 +02:00
imperosol 236860d0f5 adapt typescript to strict mode 2026-08-20 16:17:23 +02:00
imperosol 3291a78d2b split node and bundled TS config 2026-08-20 16:17:23 +02:00
thomas girod d6677b1fbd Merge pull request #1459 from ae-utbm/master
Merge back master into taiste
2026-08-20 16:17:02 +02:00
thomas girod e52fd32e06 Merge pull request #1445 from ae-utbm/pedagogy
Update pedagogy
2026-08-20 16:14:16 +02:00
thomas girod 1ae52b3bc9 Merge pull request #1454 from ae-utbm/sas-click
Change sas picture on picture click
2026-08-19 18:06:29 +02:00
klmp200 1765d70911 Move image click to frame and remove image selection 2026-08-19 18:05:18 +02:00
imperosol 4d07b94fee change sas picture on picture click 2026-08-06 19:33:51 +02:00
thomas girod b8c8768479 Merge pull request #1451 from ae-utbm/ruff
bump ruff to 0.16
2026-07-27 09:45:28 +02:00
imperosol 3be4c583f1 bump ruff to 0.16 2026-07-26 19:46:03 +02:00
imperosol 5177427549 adapt pedagogy to new UTBM UE API 2026-07-25 11:45:19 +02:00
thomas girod 2d75a3e6c2 Merge pull request #1448 from ae-utbm/remove-eurock
remove eurockéennes widget
2026-07-09 16:49:40 +02:00
imperosol 8cdb74e90f remove eurockéennes widget 2026-07-09 12:40:15 +02:00
thomas girod 847511d27f Merge pull request #1441 from ae-utbm/fix-barman-login
fix: session not properly updated on counter login
2026-06-29 10:26:29 +02:00
imperosol a75d5e06b6 fix: session not properly updated on counter login
Fixes #1440
2026-06-24 22:22:34 +02:00
thomas girod 79ec036b70 Merge pull request #1439 from ae-utbm/taiste
AEMark, max account balance, membership API and bugfixes
2026-06-24 13:13:39 +02:00
thomas girod 1c277365ed Merge pull request #1438 from ae-utbm/update-deps
Update deps
2026-06-24 09:40:33 +02:00
imperosol e7ef3d0020 update JS deps 2026-06-24 09:35:26 +02:00
imperosol 5c87bcf4b5 update python deps 2026-06-24 09:35:26 +02:00
thomas girod 92b83071ad Merge pull request #1434 from ae-utbm/election-role
use election role name for membership description
2026-06-24 07:22:47 +02:00
thomas girod 7ad469ff24 Merge pull request #1433 from ae-utbm/md
move from mistune to aemark for markdown
2026-06-24 07:22:32 +02:00
Titouan c076b80e40 Merge pull request #1437 from ae-utbm/api-membership-club
ajout requette api nouveau et ancien membre de club
2026-06-24 06:41:32 +02:00
TitouanDor dcdfb086ed ajout club dans select_related() 2026-06-23 22:56:51 +02:00
TitouanDor 5625268d6b ajout modif demandées 2026-06-23 22:13:55 +02:00
TitouanDor 648b6b6af0 modification des tests 2026-06-23 21:52:54 +02:00
TitouanDor 73ae7666c1 ajout des tests 2026-06-23 21:52:54 +02:00
TitouanDor defeef62ea modif des changement demandé 2026-06-23 21:52:54 +02:00
TitouanDor 859d724c34 ajout requette api nouveau et ancien membre de club 2026-06-23 21:52:54 +02:00
Titouan 307f89ce90 Merge pull request #1435 from ae-utbm/padding-club-list
modify padding for little screen
2026-06-20 14:55:07 +02:00
TitouanDor 44f8655b70 modify padding for little screen 2026-06-19 16:58:20 +02:00
imperosol 8f15facd8f use election role name for membership description 2026-06-19 14:06:52 +02:00
imperosol 1af1712efc move from mistune to aemark for markdown 2026-06-19 12:19:37 +02:00
thomas girod 0ba001ccda Merge pull request #1428 from ae-utbm/fix-session-barmen
CounterLogin: manage case where barman is already logged in another device
2026-06-15 12:54:34 +02:00
thomas girod feca466dbe Merge pull request #1427 from ae-utbm/max-account-balance
Max account balance
2026-06-15 12:50:43 +02:00
imperosol 519a7758c5 manage case where barman is already logged in another device 2026-06-12 10:32:16 +02:00
imperosol caa2bf66be apply review comments 2026-06-11 18:18:12 +02:00
imperosol 998efc7c6b add tests 2026-06-11 14:22:38 +02:00
imperosol 867362fb51 add translations 2026-06-11 14:22:27 +02:00
imperosol d41a3a524a max amount for eboutic refills 2026-06-11 14:21:50 +02:00
imperosol 39bbbc8878 autofocus input on counter refill 2026-06-07 14:28:49 +02:00
imperosol 5e553d91a8 max amount for counter refills 2026-06-07 14:28:21 +02:00
imperosol f6f31af975 enforce max amount on sith account 2026-06-07 14:16:12 +02:00
thomas girod 455b81cff6 Merge pull request #1424 from ae-utbm/taiste
Basket timeout, clic limit, club-election link, CGV, counter barmen and other
2026-06-06 09:46:41 +02:00
thomas girod 1957aa1fdb Merge pull request #1306 from ae-utbm/cgu
CGV
2026-06-05 13:47:23 +02:00
imperosol d73b7de903 tweaks after rebase on clic-limit 2026-06-05 13:40:18 +02:00
TitouanDor 4c5149aec9 ajout balise de traduction 2026-06-05 13:40:18 +02:00
TitouanDor 89afdba055 one CGV button for and etransaction and sith account 2026-06-05 13:40:18 +02:00
TitouanDor e9a55c6f3a correction fautes orthographes 2026-06-05 13:40:18 +02:00
TitouanDor 922c940fd5 add button for general term and condition 2026-06-05 13:40:18 +02:00
thomas girod 29c0d0be28 Merge pull request #1415 from ae-utbm/fix-permission-NewsUpdateView
fix admin getting error 403
2026-06-05 13:38:18 +02:00
thomas girod b1ab21aff7 Merge pull request #1423 from ae-utbm/update-deps
Update deps
2026-06-05 12:33:07 +02:00
imperosol 8ee385e1ae update js deps 2026-06-05 12:16:37 +02:00
imperosol d0a80a3ec8 update python deps 2026-06-05 12:16:37 +02:00
thomas girod bcc44727df Merge pull request #1414 from ae-utbm/election-roles
Election roles
2026-06-05 11:08:01 +02:00
TitouanDor d25bcec9ba fix admin getting error 403 2026-06-05 11:04:52 +02:00
imperosol f3e78f229a add tests 2026-06-05 11:02:35 +02:00
imperosol 00fb8a719f add translations 2026-06-05 11:02:35 +02:00
imperosol c4af372973 automatically apply election results 2026-06-05 11:02:35 +02:00
imperosol d119bfa0c6 create multiple elections in populate.py 2026-06-05 11:02:35 +02:00
imperosol dc2e7ff468 add translations 2026-06-05 11:02:35 +02:00
imperosol 960657404b button to create new elections 2026-06-05 11:02:35 +02:00
imperosol cd9cd81e8b add default initial values on election creation 2026-06-05 11:02:35 +02:00
imperosol 733bd49a42 feat: add ClubRole selection in election Role form 2026-06-05 11:02:35 +02:00
imperosol eb7f5def6e feat: link election Role to ClubRole 2026-06-05 11:02:35 +02:00
imperosol ba618aa3cd feat: custom ClubRoleChoiceField for club roles 2026-06-05 11:02:35 +02:00
thomas girod 1456bc8044 Merge pull request #1420 from ae-utbm/phone-number
make phone number non-nullable on BillingInfo
2026-06-05 10:28:36 +02:00
imperosol 177002b8b8 tweak django-countries settings 2026-06-05 00:43:56 +02:00
imperosol e629b36465 make BillingInfo.phone_number non-nullable 2026-06-05 00:43:56 +02:00
thomas girod 30a3911fa1 Merge pull request #1422 from ae-utbm/fix-login-button
fix: login button background-color
2026-06-05 00:31:36 +02:00
thomas girod 7c9ba29db1 Merge pull request #1413 from ae-utbm/counter-barmen
feat: `request.barmen`
2026-06-05 00:31:19 +02:00
imperosol cf31182429 fix: login button background-color 2026-06-04 18:04:52 +02:00
imperosol 29cacf8efc add tests 2026-06-03 23:51:40 +02:00
imperosol 1e592e657f update translations 2026-06-03 23:51:40 +02:00
imperosol fb1790020b remove Counter.token
Ce paramètre n'est plus utilisé, maintenant que la gestion de la session du comptoir se fait avec `request.barmen`
2026-06-03 23:51:40 +02:00
imperosol 3cf142f3f1 show barmen logged on current device in counter 2026-06-03 23:43:19 +02:00
imperosol 222b0d16a7 feat: request.barmen 2026-06-03 23:43:19 +02:00
imperosol 074ebcb011 use fragment for counter login 2026-06-03 23:43:19 +02:00
thomas girod a26e06216e Merge pull request #1421 from ae-utbm/clic-limit
fix etransaction after clic limit changes
2026-06-02 14:22:54 +02:00
imperosol 78c541dd36 fix etransaction after clic limit changes 2026-06-02 14:21:58 +02:00
thomas girod b4d76c4f85 Merge pull request #1407 from ae-utbm/clic-limit
Clic limit
2026-06-02 13:12:28 +02:00
imperosol b5a2ec78df add translations 2026-06-01 10:46:28 +02:00
imperosol 8022589902 add doc 2026-06-01 10:46:28 +02:00
imperosol 6ae73a28b4 test sold out items in eboutic 2026-06-01 10:46:28 +02:00
imperosol 7f415c6a6c clean invalid items from eboutic baskets 2026-06-01 10:46:28 +02:00
imperosol dd4887ead4 exclude products over clic limit from eboutic 2026-06-01 10:46:28 +02:00
imperosol a8b6a2e43b add clic limit to product form 2026-06-01 10:46:28 +02:00
imperosol f90cb5b91c add field Product.clic_limit 2026-06-01 10:46:28 +02:00
imperosol d604147a93 remove Product.buying_groups
Savoir quel groupe a le droit d'acheter quel produit est maintenant déterminé avec le modèle `Price`. `Product.buying_groups` avait juste été laissé temporairement pour permettre un rollback si le déploiement des prix ne se passait pas bien. Comme il n'y a pas eu de problème, on peut maintenant le retirer.
2026-06-01 10:46:28 +02:00
imperosol 3f2908eb8d feat: basket timeout 2026-06-01 10:46:28 +02:00
thomas girod e811aeaecd Merge pull request #1412 from ae-utbm/improve-mobile-counter
Improve counter click on smartphones
2026-05-31 11:48:07 +02:00
thomas girod 549a778be0 Merge pull request #1411 from ae-utbm/fix-club-role
fix: forgotten group assignation on club role update
2026-05-31 11:47:40 +02:00
thomas girod 5c42da273b Merge pull request #1392 from ae-utbm/basket-timeout
Basket timeout
2026-05-30 12:56:35 +02:00
thomas girod b8e0294df6 Merge pull request #1410 from ae-utbm/fix-payment-method
fix: wrong payment method for refills with eboutic
2026-05-30 12:41:44 +02:00
imperosol 78b24dc1e7 fix: product research with code 2026-05-28 18:10:56 +02:00
imperosol ebf0196bef improve counter basket item style 2026-05-27 18:22:07 +02:00
imperosol 362b9eea06 automatically add item to basket on counter product search 2026-05-27 18:22:07 +02:00
imperosol 3b3e33ed80 fix: forgotten group assignation on club role update 2026-05-27 12:24:27 +02:00
imperosol 649190debe fix: wrong payment method for refills with eboutic 2026-05-26 23:46:38 +02:00
imperosol 50c880719a feat: basket timeout 2026-05-22 11:38:03 +02:00
thomas girod 9ceb51a54e Merge pull request #1404 from ae-utbm/taiste
Club roles, club links, notifications and other
2026-05-22 09:43:08 +02:00
Titouan 790a1e15b1 Merge pull request #1383 from ae-utbm/taiste
MAJ cotisation, CI, style et fix
2026-05-11 13:03:22 +02:00
thomas girod b2ffcd3a37 Merge pull request #1365 from ae-utbm/taiste
Product prices, club list page rework and bug fixes
2026-05-01 19:14:03 +02:00
Titouan ca37996d6a Merge pull request #1332 from ae-utbm/taiste
Stats & Whitelist, Eurockéenne, fix pagination, Vite 8, delete unused settings
2026-03-29 16:35:16 +02:00
Titouan 173311c1d5 Merge pull request #1315 from ae-utbm/taiste
Product history, formula management, test election
2026-03-12 11:33:45 +01:00
thomas girod 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
209 changed files with 6699 additions and 4264 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.15.13 rev: v0.16.0
hooks: hooks:
- id: ruff-check # just check the code, and print the errors - id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing - id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1 rev: v0.6.1
hooks: hooks:
- id: biome-check - id: biome-check
additional_dependencies: ["@biomejs/biome@2.4.6"] additional_dependencies: ["@biomejs/biome@2.5.1"]
- repo: https://github.com/rtts/djhtml - repo: https://github.com/rtts/djhtml
rev: 3.0.11 rev: 3.0.11
hooks: hooks:
+1 -1
View File
@@ -1 +1 @@
3.12 3.14
+6 -2
View File
@@ -1,11 +1,15 @@
from typing import TYPE_CHECKING
from django.contrib import admin, messages from django.contrib import admin, messages
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from api.hashers import generate_key from api.hashers import generate_key
from api.models import ApiClient, ApiKey from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
@admin.register(ApiClient) @admin.register(ApiClient)
class ApiClientAdmin(admin.ModelAdmin): class ApiClientAdmin(admin.ModelAdmin):
+5 -1
View File
@@ -1,9 +1,13 @@
from django.http import HttpRequest from typing import TYPE_CHECKING
from ninja.security import APIKeyHeader from ninja.security import APIKeyHeader
from api.hashers import get_hasher from api.hashers import get_hasher
from api.models import ApiClient, ApiKey from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.http import HttpRequest
class ApiKeyAuth(APIKeyHeader): class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys.""" """Authentication through client api keys."""
+7 -10
View File
@@ -39,14 +39,16 @@ Example:
import operator import operator
from functools import reduce from functools import reduce
from typing import Any, Callable from typing import TYPE_CHECKING, Any, Callable
from ninja_extra.permissions import BasePermission
from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.http import HttpRequest from django.http import HttpRequest
from ninja_extra import ControllerBase from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
from counter.models import Counter
class IsInGroup(BasePermission): class IsInGroup(BasePermission):
@@ -186,12 +188,7 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter.""" """Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
if "/counter/" not in request.META.get("HTTP_REFERER", ""): return is_logged_in_counter(request)
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
+1 -1
View File
@@ -17,7 +17,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "preset": "recommended",
"style": { "style": {
"useNamingConvention": "error" "useNamingConvention": "error"
}, },
+6 -2
View File
@@ -12,12 +12,16 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from typing import TYPE_CHECKING
from django.contrib import admin from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubLink, ClubRole, LinkType, Membership from club.models import Club, ClubLink, ClubRole, LinkType, Membership
if TYPE_CHECKING:
from django.forms.models import ModelForm
from django.http import HttpRequest
@admin.register(Club) @admin.register(Club)
class ClubAdmin(admin.ModelAdmin): class ClubAdmin(admin.ModelAdmin):
+41
View File
@@ -11,6 +11,7 @@ from club.models import Club, Membership
from club.schemas import ( from club.schemas import (
ClubSchema, ClubSchema,
ClubSearchFilterSchema, ClubSearchFilterSchema,
MembershipFilterSchema,
SimpleClubSchema, SimpleClubSchema,
UserMembershipSchema, UserMembershipSchema,
) )
@@ -62,3 +63,43 @@ class UserClubController(ControllerBase):
.filter(user=user) .filter(user=user)
.select_related("club", "user", "role") .select_related("club", "user", "role")
) )
@api_controller("/clubs/members/")
class ClubMembershipController(ControllerBase):
@route.get(
"/new",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("club.view_club")],
url_name="get_new_clubs_members_since_date",
)
def fetch_new_club_members(self, filters: Query[MembershipFilterSchema]):
"""give all the members of all clubs that have joined since a given date"""
memberships = (
Membership.objects.ongoing()
.filter(start_date__gte=filters.since_date, end_date__isnull=True)
.select_related("user", "role", "club")
)
if filters.clubs_id:
memberships = memberships.filter(club_id__in=filters.clubs_id)
return memberships.order_by("start_date")
@route.get(
"/former",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("club.view_club")],
url_name="get_former_clubs_members_since_date",
)
def fetch_former_club_members(self, filters: Query[MembershipFilterSchema]):
"""give all the former members of all clubs that have left since a given date"""
memberships = Membership.objects.filter(
start_date__lt=filters.since_date,
end_date__gte=filters.since_date,
).select_related("user", "role", "club")
if filters.clubs_id:
memberships = memberships.filter(club_id__in=filters.clubs_id)
return memberships.order_by("start_date")
+58
View File
@@ -21,10 +21,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import itertools
from operator import attrgetter
from django import forms from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -46,6 +49,37 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema from counter.schemas import SaleFilterSchema
class ClubRoleChoiceIterator(ModelChoiceIterator):
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
def __iter__(self):
if self.field.empty_label is not None:
yield "", self.field.empty_label
queryset = self.queryset.select_related("club").order_by("club", "order")
groups = [
(club, [self.choice(role) for role in roles])
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
]
if len(groups) == 1:
# there is only one club involved, no need to have optgroups
yield from groups[0][1]
else:
# there are multiple clubs, optgroups are necessary to differentiate
# roles having the same name
yield from groups
class ClubRoleChoiceField(ModelChoiceField):
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
If only one club is involved, behave like the base `ModelChoiceField`.
If dealing with the roles of multiple clubs, group the roles
into a different `optgroup` for each club.
"""
iterator = ClubRoleChoiceIterator
class ClubLinkForm(forms.ModelForm): class ClubLinkForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -392,6 +426,30 @@ class ClubRoleForm(forms.ModelForm):
self.instance.order = cleaned_data["ORDER"] - 1 self.instance.order = cleaned_data["ORDER"] - 1
return cleaned_data return cleaned_data
def save(self, commit=True): # noqa: FBT002
instance: ClubRole = super().save(commit=commit)
if commit and "is_board" in self.changed_data:
# if the role was moved from board to simple member,
# remove all users with that role from the club board group.
# If the role became a board role, add users with
# that role to the club board group.
group_id = instance.club.board_group_id
if self.cleaned_data["is_board"]:
User.groups.through.objects.bulk_create(
[
User.groups.through(user_id=u, group_id=group_id)
for u in Membership.objects.ongoing()
.filter(role=instance)
.values_list("user_id", flat=True)
],
ignore_conflicts=True,
)
else:
User.groups.through.objects.filter(
user__memberships__role=instance, group_id=group_id
).delete()
return instance
class ClubRoleCreateForm(forms.ModelForm): class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role. """Form to create a club role.
@@ -1,12 +1,16 @@
# Generated by Django 4.2.16 on 2024-11-20 17:08 # Generated by Django 4.2.16 on 2024-11-20 17:08
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
import django.db.models.functions.datetime import django.db.models.functions.datetime
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import localdate from django.utils.timezone import localdate
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
# Before the club role rework, the maximum free role # Before the club role rework, the maximum free role
# was the hardcoded highest non-board role # was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1 MAXIMUM_FREE_ROLE = 1
@@ -1,10 +1,14 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59 # Generated by Django 5.2.3 on 2025-06-21 21:59
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
PRESIDENCY_ROLES = [10, 9] PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1 MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = { SITH_CLUB_ROLES = {
+1 -2
View File
@@ -25,8 +25,7 @@ class Migration(migrations.Migration):
"url_base", "url_base",
models.URLField( models.URLField(
help_text=( help_text=(
"The base url that links with this type " "The base url that links with this type must respect"
"must respect (e.g. `https://www.instagram.com`)"
), ),
unique=True, unique=True,
verbose_name="url base", verbose_name="url base",
+1 -4
View File
@@ -793,10 +793,7 @@ class LinkType(models.Model):
url_base = models.URLField( url_base = models.URLField(
"url base", "url base",
unique=True, unique=True,
help_text=_( help_text=_("The base url that links with this type must respect"),
"The base url that links with this type must respect (e.g. `%(url)s`)"
)
% {"url": "https://www.instagram.com"},
) )
icon = models.CharField( icon = models.CharField(
_("icon"), _("icon"),
+7
View File
@@ -1,3 +1,4 @@
from datetime import date
from typing import Annotated from typing import Annotated
from django.db.models import Q from django.db.models import Q
@@ -79,3 +80,9 @@ class UserMembershipSchema(ModelSchema):
club: SimpleClubSchema club: SimpleClubSchema
role: ClubRoleSchema role: ClubRoleSchema
user: SimpleUserSchema
class MembershipFilterSchema(FilterSchema):
since_date: Annotated[date, FilterLookup("date__lte")]
clubs_id: set[int] | None = None
@@ -1,7 +1,6 @@
import type { TomOption } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/src/utils";
import type { escape_html } from "tom-select/dist/types/utils"; import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts"; import { registerComponent } from "#core:utils/web-components";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi"; import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select") @registerComponent("club-ajax-select")
@@ -10,7 +9,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name"; protected labelField = "name";
protected searchField = ["code", "name"]; protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string) {
const resp = await clubSearchClub({ query: { search: query } }); const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) { if (resp.data) {
return resp.data.results; return resp.data.results;
+7
View File
@@ -45,3 +45,10 @@
} }
} }
} }
@media screen and (max-width: 575px){
#club-list{
padding-left: 0;
padding-right: 0;
}
}
+11 -6
View File
@@ -15,9 +15,6 @@
{% endblock %} {% endblock %}
{% else %} {% else %}
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%} {% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %} {% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %} {%- endblock %}
@@ -26,6 +23,12 @@
{%- endblock %} {%- endblock %}
{% endif %} {% endif %}
{% block additional_css %}
{% if not is_fragment %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endif %}
{% endblock %}
{% from "core/macros.jinja" import paginate_htmx %} {% from "core/macros.jinja" import paginate_htmx %}
{% block content %} {% block content %}
@@ -33,15 +36,17 @@
<h3>{% trans %}Filters{% endtrans %}</h3> <h3>{% trans %}Filters{% endtrans %}</h3>
<form <form
id="club-list-filters" id="club-list-filters"
hx-get="{{ url("club:club_list") }}" method="GET"
hx-action="{{ url("club:club_list") }}"
hx-target="#content" hx-target="#content"
hx-swap="outerHtml" hx-swap="innerHTML"
hx-push-url="true" hx-push-url="true"
hx-disable="find input, find button"
> >
<div class="row gap-4x"> <div class="row gap-4x">
{{ form }} {{ form }}
</div> </div>
<button type="submit" class="btn btn-blue margin-bottom"> <button class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %} <i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button> </button>
</form> </form>
@@ -7,7 +7,7 @@
<form <form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}" hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disabled-elt="find input[type='submit']" hx-disable="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#member-fragment-container" hx-target="#member-fragment-container"
id="add_club_members_form" id="add_club_members_form"
+4 -1
View File
@@ -1,9 +1,9 @@
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.db import ProgrammingError from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
@@ -14,6 +14,9 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User from core.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_club_queryset_having_board_member(): def test_club_queryset_having_board_member():
@@ -0,0 +1,203 @@
from datetime import timedelta
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import User
class TestMembershipAPI(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = baker.make(User)
perm = Permission.objects.get(codename="view_club")
cls.user.user_permissions.add(perm)
cls.clubs = baker.make(Club, _quantity=3, is_active=True)
cls.roles = baker.make(ClubRole, _quantity=3, is_active=True)
cls.expectedNumQueries = 5
# Clean existing data to avoid side effects
Membership.objects.all().delete()
cls.memberships = [
# on going
Membership.objects.create(
club=cls.clubs[0],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=1),
),
# on going
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[1],
start_date=localdate() - timedelta(days=1),
),
# former
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[2],
start_date=localdate() - timedelta(weeks=2),
end_date=localdate() - timedelta(days=6),
),
# on going
Membership.objects.create(
club=cls.clubs[2],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=3),
),
# former
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[2],
start_date=localdate() - timedelta(days=4),
end_date=localdate() - timedelta(days=3),
),
# on going
Membership.objects.create(
club=cls.clubs[2],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(days=1),
),
# former
Membership.objects.create(
club=cls.clubs[0],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=6),
end_date=localdate() - timedelta(days=3),
),
# former
Membership.objects.create(
club=cls.clubs[2],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=8),
end_date=localdate() - timedelta(days=6),
),
# former
Membership.objects.create(
club=cls.clubs[1],
user=subscriber_user.make(),
role=cls.roles[0],
start_date=localdate() - timedelta(weeks=8),
end_date=localdate() - timedelta(weeks=7, days=5),
),
]
class TestNewMembershipAPI(TestMembershipAPI):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.url = reverse("api:get_new_clubs_members_since_date")
def test_new_membership_one_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date, "clubs_id": self.clubs[0].id}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[0].id]
assert membership_ids == expected_ids
def test_new_membership_multiple_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {
"since_date": since_date,
"clubs_id": [self.clubs[0].id, self.clubs[1].id],
}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[0].id, self.memberships[1].id]
assert membership_ids == expected_ids
def test_new_membership_all_clubs(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [
self.memberships[0].id,
self.memberships[1].id,
self.memberships[5].id,
]
assert membership_ids == expected_ids
class TestFormerMembershipAPI(TestMembershipAPI):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.url = reverse("api:get_former_clubs_members_since_date")
def test_former_membership_one_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date, "clubs_id": self.clubs[1].id}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[2].id]
assert membership_ids == expected_ids
def test_new_membership_multiple_club(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {
"since_date": since_date,
"clubs_id": [self.clubs[1].id, self.clubs[0].id],
}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [self.memberships[6].id, self.memberships[2].id]
assert membership_ids == expected_ids
def test_new_membership_all_clubs(self):
self.client.force_login(self.user)
since_date = localdate() - timedelta(weeks=1)
arg = {"since_date": since_date}
with self.assertNumQueries(self.expectedNumQueries):
response = self.client.get(self.url, query_params=arg)
assert response.status_code == 200
data = response.json()
membership_ids = [e["id"] for e in data]
expected_ids = [
self.memberships[7].id,
self.memberships[6].id,
self.memberships[2].id,
]
assert membership_ids == expected_ids
+32 -2
View File
@@ -1,9 +1,10 @@
from collections.abc import Callable from typing import TYPE_CHECKING
import pytest import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -13,6 +14,9 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
def make_club(): def make_club():
# unittest-style tests cannot use fixture, so we create a function # unittest-style tests cannot use fixture, so we create a function
@@ -239,7 +243,7 @@ class TestClubRoleUpdate(TestCase):
def test_president_moves_itself_out_of_the_presidency(self): def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency, """Test that if the user moves its own role out of the presidency,
then it's redirected to another page and loses access to the update page.""" then it loses access to the update page."""
self.payload["roles-0-is_presidency"] = False self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload) res = self.client.post(self.url, data=self.payload)
@@ -251,3 +255,29 @@ class TestClubRoleUpdate(TestCase):
res = self.client.get(self.url) res = self.client.get(self.url)
assert res.status_code == 403 assert res.status_code == 403
def test_role_stops_being_board(self):
"""Test that if a role stops being a board role,
its users lose the club board group."""
self.payload["roles-0-is_board"] = False
self.payload["roles-0-is_presidency"] = False
self.payload["roles-1-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert not self.user.groups.contains(self.club.board_group)
def test_role_becomes_board(self):
"""Test that if a role becomes a board role,
its active users get the club board group"""
members = [
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
]
self.payload["roles-2-is_board"] = True
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
# the second membership is finished, so its user shouldn't get the role
assert members[0].user.groups.contains(self.club.board_group)
assert not members[1].user.groups.contains(self.club.board_group)
+5 -1
View File
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
import pytest import pytest
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -7,6 +8,9 @@ from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_club_board_member_cannot_edit_club_properties(client: Client): def test_club_board_member_cannot_edit_club_properties(client: Client):
+4 -1
View File
@@ -1,6 +1,6 @@
import itertools import itertools
from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -19,6 +19,9 @@ from club.tests.base import TestClub
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
class TestMembershipQuerySet(TestClub): class TestMembershipQuerySet(TestClub):
def test_ongoing(self): def test_ongoing(self):
+6 -2
View File
@@ -1,15 +1,19 @@
from typing import TYPE_CHECKING
import pytest import pytest
from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User from core.models import PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client): def test_page_display_on_club_main_page(client: Client):
+5 -1
View File
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
import pytest import pytest
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
@@ -7,6 +8,9 @@ from club.models import Club
from com.models import Poster from com.models import Poster
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"]) @pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
+4 -1
View File
@@ -1,8 +1,8 @@
import csv import csv
import itertools import itertools
from typing import TYPE_CHECKING
import pytest import pytest
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
@@ -12,6 +12,9 @@ from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer, Product, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client): def test_sales_page_doesnt_crash(client: Client):
+4 -1
View File
@@ -1,4 +1,5 @@
from datetime import date from datetime import date
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
@@ -9,11 +10,13 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from core.models import User
class PosterForm(forms.ModelForm): class PosterForm(forms.ModelForm):
class Meta: class Meta:
+7 -2
View File
@@ -1,11 +1,10 @@
from pathlib import Path from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain from django.contrib.syndication.views import add_domain
from django.db.models import Count, OuterRef, QuerySet, Subquery from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from ical.calendar import Calendar from ical.calendar import Calendar
@@ -14,6 +13,12 @@ from ical.event import Event
from ical.types import Frequency, Recur from ical.types import Frequency, Recur
from com.models import News, NewsDate from com.models import News, NewsDate
if TYPE_CHECKING:
from pathlib import Path
from django.http import HttpRequest
from core.models import User from core.models import User
+1 -1
View File
@@ -1,13 +1,13 @@
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
from aemark import markdown
from ninja import FilterLookup, FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver from ninja_extra import service_resolver
from ninja_extra.context import RouteContext from ninja_extra.context import RouteContext
from club.schemas import ClubProfileSchema from club.schemas import ClubProfileSchema
from com.models import News, NewsDate from com.models import News, NewsDate
from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema): class NewsDateFilterSchema(FilterSchema):
@@ -1,4 +1,9 @@
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core"; import {
Calendar,
type EventClickArg,
type EventContentArg,
type EventInput,
} from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal"; import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb"; import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr"; import frLocale from "@fullcalendar/core/locales/fr";
@@ -6,8 +11,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar"; import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list"; import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html"; import { type HTMLTemplateResult, html, render } from "lit-html";
import { makeUrl } from "#core:utils/api.ts"; import { type GenericEndpoint, makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { import {
calendarCalendarInternal, calendarCalendarInternal,
calendarCalendarUnpublished, calendarCalendarUnpublished,
@@ -19,11 +24,11 @@ import {
@registerComponent("ics-calendar") @registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") { export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"]; static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar: Calendar; private calendar!: Calendar;
private locale = "en"; private locale? = "en";
private canModerate = false; private canModerate? = false;
private canDelete = false; private canDelete? = false;
private helpUrl = ""; private helpUrl? = "";
// Hack variable to detect recurring events // Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules // The underlying ics library doesn't include any info about rrules
@@ -35,10 +40,12 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.locale = newValue; this.locale = newValue;
} }
if (name === "can_moderate") { if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true"; this.canModerate =
typeof newValue === "string" && newValue.toLowerCase() === "true";
} }
if (name === "can_delete") { if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true"; this.canDelete =
typeof newValue === "string" && newValue.toLowerCase() === "true";
} }
if (name === "ics-help-url") { if (name === "ics-help-url") {
@@ -94,7 +101,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString() .toString()
.split("/") .split("/")
.filter((s) => s) // Remove blank characters .filter((s) => s) // Remove blank characters
.pop(), .pop() as string,
10, 10,
); );
} }
@@ -159,7 +166,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents(); this.refreshEvents();
} }
async getEventSources() { async getEventSources(): Promise<EventInput[]> {
const tagRecurringEvents = (eventData: EventImpl) => { const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url // This functions tags events with a similar event url
// We rely on the fact that the event url is always the same // We rely on the fact that the event url is always the same
@@ -173,14 +180,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}; };
return [ return [
{ {
url: `${await makeUrl(calendarCalendarInternal)}`, url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
format: "ics", format: "ics",
className: "internal", className: "internal",
cache: false, cache: false,
eventDataTransform: tagRecurringEvents, eventDataTransform: tagRecurringEvents,
}, },
{ {
url: `${await makeUrl(calendarCalendarUnpublished)}`, url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
format: "ics", format: "ics",
color: "red", color: "red",
className: "unpublished", className: "unpublished",
@@ -213,7 +220,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title} ${event.title}
</h4> </h4>
<span class="event-details-row-content"> <span class="event-details-row-content">
${this.formatDate(event.start)} - ${this.formatDate(event.end)} ${this.formatDate(event.start as Date)} - ${this.formatDate(event.end as Date)}
</span> </span>
</div> </div>
`; `;
@@ -251,7 +258,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[]; const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) { if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) { if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html` const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}"> <button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")} <i class="fa fa-check"></i>${gettext("Publish")}
@@ -338,9 +345,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied"); button.classList.remove("text-copied");
} }
button.setAttribute("tooltip", gettext("Link copied")); button.setAttribute("tooltip", gettext("Link copied"));
navigator.clipboard.writeText( await navigator.clipboard.writeText(
new URL( new URL(
await makeUrl(calendarCalendarInternal), await makeUrl(calendarCalendarInternal as GenericEndpoint),
window.location.origin, window.location.origin,
).toString(), ).toString(),
); );
@@ -1,4 +1,9 @@
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi"; import {
newsDeleteNews,
newsFetchNewsDates,
newsPublishNews,
type PaginatedResponseSchemaNewsDateSchema,
} from "#openapi";
// This will be used in jinja templates, // This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript // so we cannot use real enums as those are purely an abstraction of Typescript
@@ -64,7 +69,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake-case // biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 }, query: { news_id: this.newsId, page: 1, page_size: 1 },
}); });
return response.data.count;
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
}, },
weeklyEventWarningMessage(nbEvents: number): string { weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi"; import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> { type ParsedNewsDateSchema = Omit<NewsDateSchema, "start_date" | "end_date"> & {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date; start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date; end_date: Date;
} };
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({ Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,6 +32,11 @@ document.addEventListener("alpine:init", () => {
page_size: this.pageSize, page_size: this.pageSize,
}, },
}); });
if (response.response === undefined || response.data === undefined) {
// response may be undefined, because error may be
// from building the request object itself or from a network error
return;
}
if (response.response.status === 404) { if (response.response.status === 404) {
this.hasNext = false; this.hasNext = false;
} else if (response.data.next === null) { } else if (response.data.next === null) {
@@ -44,7 +49,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false; this.loading = false;
}, },
groupedDates(): Record<string, NewsDateSchema[]> { groupedDates(): Record<string, ParsedNewsDateSchema[]> {
return this.newsDates return this.newsDates
.map( .map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({ (date: NewsDateSchema): ParsedNewsDateSchema => ({
+5 -2
View File
@@ -1,10 +1,11 @@
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import pytest import pytest
from aemark import markdown
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
@@ -13,9 +14,11 @@ from pytest_django.asserts import assertNumQueries
from com.ics_calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User from core.models import User
if TYPE_CHECKING:
from django.http import HttpResponse
def accel_redirect_to_file(response: HttpResponse) -> Path | None: def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", "")) redirect = Path(response.headers.get("X-Accel-Redirect", ""))
+1 -1
View File
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
form_class = NewsForm form_class = NewsForm
template_name = "com/news_edit.jinja" template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
permission_required = "com.edit_news" permission_required = "com.change_news"
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) # Does the saving part response = super().form_valid(form) # Does the saving part
+3 -13
View File
@@ -1,9 +1,8 @@
from typing import Annotated, Any, Literal from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen from annotated_types import Ge, Le, MinLen
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query from ninja import File, Query
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
@@ -18,7 +17,6 @@ from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema, GroupSchema,
MarkdownSchema,
SithFileSchema, SithFileSchema,
UploadedFileSchema, UploadedFileSchema,
UploadedImage, UploadedImage,
@@ -26,26 +24,18 @@ from core.schemas import (
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
UserSchema, UserSchema,
ValidationErrorSchema,
) )
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@api_controller("/markdown")
class MarkdownController(ControllerBase):
@route.post("", url_name="markdown")
def render_markdown(self, body: MarkdownSchema):
"""Convert the markdown text into html."""
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/upload") @api_controller("/upload")
class UploadController(ControllerBase): class UploadController(ControllerBase):
@route.post( @route.post(
"/image", "/image",
response={ response={
200: UploadedFileSchema, 200: UploadedFileSchema,
422: dict[Literal["detail"], list[dict[str, Any]]], 422: ValidationErrorSchema,
403: dict[Literal["detail"], str], 403: dict[Literal["detail"], str],
}, },
permissions=[HasPerm("core.add_quickuploadimage")], permissions=[HasPerm("core.add_quickuploadimage")],
+26 -27
View File
@@ -2,12 +2,9 @@
<h1>Markdown-AE Documentation</h1> <h1>Markdown-AE Documentation</h1>
<p>Le Markdown le plus standard se trouve documenté ici: <p>Le Markdown le plus standard se trouve documenté ici:
<a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br /> <a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br />
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE, Si cette page nest pas exhaustive vis à vis de la syntaxe du site AE,
elle a au moins le mérite de bien documenter le Markdown original.</p> elle a au moins le mérite de bien documenter le Markdown original.</p>
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/lepture/mistune">mistune</a>.<br /> <p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/kivikakk/comrak">comrak</a>.</p>
Les plus aventureux pourront aller lire ses <a href="https://github.com/lepture/mistune/blob/master/tests/fixtures">tests</a>
afin d'en connaître la syntaxe le plus finement possible.<br />
En pratique, cette page devrait déjà résumer une bonne partie.</p>
<h2>Basique</h2> <h2>Basique</h2>
<ul> <ul>
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li> <li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
@@ -15,8 +12,8 @@ En pratique, cette page devrait déjà résumer une bonne partie.</p>
<li><u>Souligner</u> le texte : <code>__texte__</code></li> <li><u>Souligner</u> le texte : <code>__texte__</code></li>
<li><del>Barrer du texte</del> : <code>~~texte~~</code></li> <li><del>Barrer du texte</del> : <code>~~texte~~</code></li>
<li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li> <li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li>
<li>Mettre du texte^en exposant^ : <code>&lt;sup&gt;texte&lt;/sup&gt;</code></li> <li>Mettre du texte<sup>en exposant</sup> : <code>&lt;sup&gt;texte&lt;/sup&gt;</code></li>
<li>Mettre du texte~en indice~ : <code>&lt;sub&gt;texte&lt;/sub&gt;</code></li> <li>Mettre du texte<sub>en indice</sub> : <code>&lt;sub&gt;texte&lt;/sub&gt;</code></li>
</ul> </ul>
<h2>Liens</h2> <h2>Liens</h2>
<ul> <ul>
@@ -28,10 +25,10 @@ En pratique, cette page devrait déjà résumer une bonne partie.</p>
</ul> </ul>
<p><a href="http://www.site.com">nom du lien</a></p> <p><a href="http://www.site.com">nom du lien</a></p>
<ul> <ul>
<li>Les liens peuvent être internes au site de l'AE, on peut dès lors éviter d'entrer <li>Les liens peuvent être internes au site de lAE, on peut dès lors éviter dentrer
l'adresse complète d'une page : <code>[nom du lien](page://nomDeLaPage)</code></li> ladresse complète dune page : <code>[nom du lien](page://nomDeLaPage)</code></li>
</ul> </ul>
<p><a href="/page/nomDeLaPage/">nom du lien</a></p> <p><a href="/page/nomDeLaPage">nom du lien</a></p>
<ul> <ul>
<li>On peut également utiliser une image pour les liens : <li>On peut également utiliser une image pour les liens :
<code>[nom du lien]![images/imageDuSiteAE.png](/chemin/vers/image.png titre optionnel)(options)</code></li> <code>[nom du lien]![images/imageDuSiteAE.png](/chemin/vers/image.png titre optionnel)(options)</code></li>
@@ -112,7 +109,7 @@ etc...
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p>L'alignement dans les cellules est géré comme suit, avec les ':' sur la ligne en dessous du titre:</p> <p>Lalignement dans les cellules est géré comme suit, avec les : sur la ligne en dessous du titre:</p>
<pre><code>| Titre | Titre2 | Titre3 | <pre><code>| Titre | Titre2 | Titre3 |
|:-------|:------:|-------:| |:-------|:------:|-------:|
| gauche | centre | droite | | gauche | centre | droite |
@@ -120,16 +117,16 @@ etc...
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="text-align:left">Titre</th> <th align="left">Titre</th>
<th style="text-align:center">Titre2</th> <th align="center">Titre2</th>
<th style="text-align:right">Titre3</th> <th align="right">Titre3</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td style="text-align:left">gauche</td> <td align="left">gauche</td>
<td style="text-align:center">centre</td> <td align="center">centre</td>
<td style="text-align:right">droite</td> <td align="right">droite</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -141,11 +138,11 @@ etc...
![image de 350 pixels de large](/static/core/img/logo.png?350 &quot;Image de 350 pixels&quot;) ![image de 350 pixels de large](/static/core/img/logo.png?350 &quot;Image de 350 pixels&quot;)
![image de 350x100 pixels](/static/core/img/logo.png?350x100 &quot;Image de 350x100 pixels&quot;) ![image de 350x100 pixels](/static/core/img/logo.png?350x100 &quot;Image de 350x100 pixels&quot;)
</code></pre> </code></pre>
<p><img src="/static/core/img/logo.png" alt="image à 50%" title="Image à 50%" style="width:50%;" /><br /> <p><img src="/static/core/img/logo.png" style="width:50%" alt="image à 50%" title="Image à 50%" /><br />
Image à 50% de la largeur de la page.</p> Image à 50% de la largeur de la page.</p>
<p><img src="/static/core/img/logo.png" alt="image de 350 pixels de large" title="Image de 350 pixels" style="width:350px;" /><br /> <p><img src="/static/core/img/logo.png" style="width:350px" alt="image de 350 pixels de large" title="Image de 350 pixels" /><br />
Image de 350 pixels de large.</p> Image de 350 pixels de large.</p>
<p><img src="/static/core/img/logo.png" alt="image de 350x100 pixels" title="Image de 350x100 pixels" style="width:350px;height:100px;" /><br /> <p><img src="/static/core/img/logo.png" style="width:350px;height:100px" alt="image de 350x100 pixels" title="Image de 350x100 pixels" /><br />
Image de 350x100 pixels.</p> Image de 350x100 pixels.</p>
<p>(devrait pouvoir détecter si vidéo ou non)</p> <p>(devrait pouvoir détecter si vidéo ou non)</p>
<h2>Blocs de citations</h2> <h2>Blocs de citations</h2>
@@ -159,9 +156,9 @@ Image de 350x100 pixels.</p>
un bloc de un bloc de
citation</p> citation</p>
</blockquote> </blockquote>
<p>Il est possible d'intégrer de la syntaxe Markdown-AE dans un tel bloc.</p> <p>Il est possible dintégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
<h2>Note de bas de page</h2> <h2>Note de bas de page</h2>
<p>On les crée comme ça<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>:</p> <p>On les crée comme ça<sup class="footnote-ref"><a href="#fn-key" id="fnref-key" data-footnote-ref>1</a></sup>:</p>
<pre><code>Je fais une note[^clef]. <pre><code>Je fais une note[^clef].
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas [^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
@@ -175,13 +172,15 @@ citation</p>
</code></pre> </code></pre>
<h2>Échapper des caractères</h2> <h2>Échapper des caractères</h2>
<ul> <ul>
<li>Il est possible d'ignorer un caractère spécial en l'échappant à l'aide d'un \</li> <li>Il est possible dignorer un caractère spécial en léchappant à laide dun \</li>
<li>L'échappement de blocs de codes complet se fera à l'aide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li> <li>Léchappement de blocs de codes complet se fera à laide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li>
</ul> </ul>
<h2>Autres (hérité de l'ancien wiki)</h2> <h2>Autres (hérité de lancien wiki)</h2>
<p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p> <p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p>
<section class="footnotes"> <section class="footnotes" data-footnotes>
<ol> <ol>
<li id="fn-1"><p>ceci est le contenu de ma clef<a href="#fnref-1" class="footnote">&#8617;</a></p></li> <li id="fn-key">
<p>ceci est le contenu de ma clef <a href="#fnref-key" class="footnote-backref" data-footnote-backref data-footnote-backref-idx="1" aria-label="Back to reference 1"></a></p>
</li>
</ol> </ol>
</section> </section>
+1 -4
View File
@@ -7,10 +7,7 @@ https://www.markdownguide.org/basic-syntax.
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE, Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
elle a au moins le mérite de bien documenter le Markdown original. elle a au moins le mérite de bien documenter le Markdown original.
Le réel parseur du site AE est une version tunée de [mistune](https://github.com/lepture/mistune). Le réel parseur du site AE est une version tunée de [comrak](https://github.com/kivikakk/comrak).
Les plus aventureux pourront aller lire ses [tests](https://github.com/lepture/mistune/blob/master/tests/fixtures)
afin d'en connaître la syntaxe le plus finement possible.
En pratique, cette page devrait déjà résumer une bonne partie.
## Basique ## Basique
+1 -2
View File
@@ -22,11 +22,10 @@
# #
from aemark import markdown
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from core.markdown import markdown
class Command(BaseCommand): class Command(BaseCommand):
help = "Output the fully rendered SYNTAX.md file" help = "Output the fully rendered SYNTAX.md file"
+138 -54
View File
@@ -20,7 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from datetime import date, timedelta from datetime import date, datetime, timedelta
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, NamedTuple from typing import ClassVar, NamedTuple
@@ -33,7 +33,8 @@ from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import localdate from django.utils.lorem_ipsum import paragraphs
from django.utils.timezone import localdate, now
from PIL import Image from PIL import Image
from club.models import Club, ClubLink, ClubRole, LinkType, Membership from club.models import Club, ClubLink, ClubRole, LinkType, Membership
@@ -43,13 +44,14 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from counter.models import ( from counter.models import (
Counter, Counter,
CounterSellers,
Price, Price,
Product, Product,
ProductType, ProductType,
ReturnableProduct, ReturnableProduct,
StudentCard, StudentCard,
) )
from election.models import Candidature, Election, ElectionList, Role from election.models import Candidature, Election, ElectionList, Role, Vote
from forum.models import Forum from forum.models import Forum
from pedagogy.models import UE from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
@@ -364,62 +366,15 @@ class Command(BaseCommand):
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE") Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
# Add barman to counter # Add barman to counter
Counter.sellers.through.objects.bulk_create( CounterSellers.objects.bulk_create(
[ [
Counter.sellers.through(counter_id=1, user=skia), # MDE CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
Counter.sellers.through(counter_id=2, user=krophil), # Foyer CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
] ]
) )
# Create an election # Create an election
el = Election.objects.create( self._create_elections(groups, clubs, skia, sli, krophil)
title="Élection 2017",
description="La roue tourne",
start_candidature="1942-06-12 10:28:45+01",
end_candidature="2042-06-12 10:28:45+01",
start_date="1942-06-12 10:28:45+01",
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
listeT = ElectionList.objects.create(title="Troll", election=el)
pres = Role.objects.create(
election=el, title="Président AE", description="Roi de l'AE"
)
resp = Role.objects.create(
election=el, title="Co Respo Info", max_choice=2, description="Ghetto++"
)
Candidature.objects.bulk_create(
[
Candidature(
role=resp,
user=skia,
election_list=liste,
program="Refesons le site AE",
),
Candidature(
role=resp,
user=sli,
election_list=liste,
program="Vasy je deviens mon propre adjoint",
),
Candidature(
role=resp,
user=krophil,
election_list=listeT,
program="Le Pôle Troll !",
),
Candidature(
role=pres,
user=sli,
election_list=listeT,
program="En fait j'aime pas l'info, je voulais faire GMC",
),
]
)
# Forum # Forum
room = Forum.objects.create( room = Forum.objects.create(
@@ -1010,3 +965,132 @@ class Command(BaseCommand):
BanGroup.objects.create(name="Banned from buying alcohol", description="") BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", description="") BanGroup.objects.create(name="Banned from counters", description="")
BanGroup.objects.create(name="Banned to subscribe", description="") BanGroup.objects.create(name="Banned to subscribe", description="")
def _create_elections(
self,
groups: PopulatedGroups,
clubs: PopulatedClubs,
skia: User,
sli: User,
krophil: User,
):
"""Populate elections.
4 elections are created :
- one that has not started yet,
- one on the candidature period
- one on the vote period
- one that is finished
All elections have two lists, are linked to the AE and Troll clubs,
and have one role for each board role of thos two clubs, plus
an additional role linked to no club roles.
The ongoing vote and finished elections have candidates.
The finished election has 10 voters.
"""
def election_factory(title: str, start_candidature: datetime):
return Election(
title=title,
description="",
start_candidature=start_candidature,
end_candidature=start_candidature + timedelta(days=7),
start_date=start_candidature + timedelta(days=7),
end_date=start_candidature + timedelta(days=14),
)
# create the elections
elections = Election.objects.bulk_create(
[
election_factory("Election terminée", now() - timedelta(days=14)),
election_factory("Votes en cours", now() - timedelta(days=7)),
election_factory("Candidatures en cours", now()),
election_factory("Election à venir", now() + timedelta(days=7)),
]
)
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
# set the groups (all elections have the same groups)
groups.public.viewable_elections.set(elections)
clubs.ae.board_group.editable_elections.set(elections)
groups.subscribers.candidate_elections.set(elections)
groups.subscribers.votable_elections.set(elections)
# link elections to clubs (AE and Troll for all elections)
Election.clubs.through.objects.bulk_create(
[
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
*[
Election.clubs.through(club=clubs.troll, election=e)
for e in elections
],
]
)
# Create lists (all elections have two lists)
ElectionList.objects.bulk_create(
[
*[ElectionList(title="Candidat libre", election=e) for e in elections],
*[ElectionList(title="Troll", election=e) for e in elections],
]
)
# Create roles.
# Elections have a role for each board club role of AE and Troll,
# +an additional role linked to no club role
club_roles = list(
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
.select_related("club")
.order_by("club_id", "order")
)
Role.objects.bulk_create(
[
*[
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
for r in club_roles
for e in elections
],
*[Role(election=e, title="Rôle libre") for e in elections],
]
)
# create candidatures for ongoing_vote and finished elections
candidatures = []
lipsum = "\n\n".join(paragraphs(2))
for election in ongoing_vote, finished:
lists = list(election.election_lists.order_by("id"))
roles = list(election.roles.order_by("order")[:3])
candidatures.extend(
[
Candidature(
role=roles[0], user=skia, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[1], user=sli, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[2], user=krophil, election_list=lists[1], program=""
),
Candidature(
role=roles[2], user=sli, election_list=lists[0], program=lipsum
),
]
)
candidatures = Candidature.objects.bulk_create(candidatures)
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
votes = Vote.objects.bulk_create(
[
*[Vote(role=skia.role) for _ in range(6)],
*[Vote(role=sli_vp.role) for _ in range(8)],
*[Vote(role=krophil.role) for _ in range(9)],
]
)
skia.votes.set(votes[:6])
sli_vp.votes.set(votes[6:14])
krophil.votes.set(votes[14:20])
sli_treso.votes.set(votes[20:23])
finished.voters.set(list(User.objects.all()[:10]))
-132
View File
@@ -1,132 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import mistune
from django.urls import reverse
from mistune import HTMLRenderer, Markdown
if TYPE_CHECKING:
from mistune import InlineParser, InlineState
# match __text__, without linebreak in the text, nor backslash prepending an underscore
# Examples :
# - "__text__" : OK
# - "__te xt__" : OK
# - "__te_xt__" : nope (underscore in the middle)
# - "__te\_xt__" : Ok (the middle underscore is escaped)
# - "__te\nxt__" : nope (there is a linebreak in the text)
# - "\__text__" : nope (one of the underscores have a backslash prepended)
# - "\\__text__" : Ok (the backslash is ignored, because there is another backslash before)
UNDERLINED_RE = (
r"(?<!\\)(?:\\{2})*" # ignore if there is an odd number of backslashes before
r"_{2}" # two underscores
r"(?P<underlined>([^\\_]|\\.)+)" # the actual text
r"_{2}" # closing underscores
)
SITH_LINK_RE = (
r"\[(?P<page_name>[\w\s]+)\]" # [nom du lien]
r"\(page:\/\/" # (page://
r"(?P<page_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
r"\)" # )
)
CUSTOM_DIMENSIONS_IMAGE_RE = (
r"\[(?P<img_name>[\w\s]+)\]" # [nom du lien]
r"\(img:\/\/" # (img://
r"(?P<img_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
r"\)" # )
)
def parse_underline(_inline: InlineParser, m: re.Match, state: InlineState):
state.append_token({"type": "underline", "raw": m.group("underlined")})
return m.end()
def underline(md_instance: Markdown):
md_instance.inline.register(
"underline",
UNDERLINED_RE,
parse_underline,
before="emphasis",
)
md_instance.renderer.register("underline", lambda _, text: f"<u>{text}</u>")
def parse_sith_link(_inline: InlineParser, m: re.Match, state: InlineState):
page_name = m.group("page_name")
page_slug = m.group("page_slug")
state.append_token(
{
"type": "link",
"children": [{"type": "text", "raw": page_name}],
"attrs": {"url": reverse("core:page", kwargs={"page_name": page_slug})},
}
)
return m.end()
def sith_link(md_instance: Markdown):
md_instance.inline.register(
"sith_link",
SITH_LINK_RE,
parse_sith_link,
before="emphasis",
)
# no custom renderer here.
# we just add another parsing rule, but render it as if it was
# a regular markdown link
class SithRenderer(HTMLRenderer):
def image(self, text: str, url: str, title=None) -> str:
if "?" not in url:
return super().image(text, url, title)
new_url, params = url.rsplit("?", maxsplit=1)
m = re.match(r"^(?P<width>\d+(%|px)?)(x(?P<height>\d+(%|px)?))?$", params)
if not m:
return super().image(text, url, title)
width, height = m.group("width"), m.group("height")
if not width.endswith(("%", "px")):
width += "px"
style = f"width:{width};"
if height is not None:
if not height.endswith(("%", "px")):
height += "px"
style += f"height:{height};"
return super().image(text, new_url, title).replace("/>", f'style="{style}" />')
markdown = mistune.create_markdown(
renderer=SithRenderer(escape=True),
plugins=[
underline,
sith_link,
"strikethrough",
"footnotes",
"table",
"spoiler",
"subscript",
"superscript",
"url",
],
)
@@ -1,9 +1,13 @@
# Generated by Django 4.2.17 on 2025-01-04 16:42 # Generated by Django 4.2.17 on 2025-01-04 16:42
from typing import TYPE_CHECKING
from django.db import migrations from django.db import migrations
from django.db.migrations.state import StateApps
from django.db.models import F from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def invert_is_manually_manageable(apps: StateApps, schema_editor): def invert_is_manually_manageable(apps: StateApps, schema_editor):
"""Invert `is_manually_manageable`. """Invert `is_manually_manageable`.
@@ -1,9 +1,13 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30 # Generated by Django 4.2.17 on 2024-12-31 13:30
from typing import TYPE_CHECKING
import django.contrib.auth.models import django.contrib.auth.models
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps from django.db.migrations.state import StateApps
@@ -1,11 +1,15 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59 # Generated by Django 5.2.12 on 2026-05-01 08:59
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def set_updated_at(apps: StateApps, schema_editor): def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile") SithFile = apps.get_model("core", "SithFile")
+10 -4
View File
@@ -19,6 +19,16 @@ from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)] NonEmptyStr = Annotated[str, MinLen(1)]
class ValidationErrorSchema(Schema):
class ValidationErrorItem(Schema):
loc: list[str | int]
msg: str
type: str
ctx: dict[str, str]
detail: list[ValidationErrorItem]
class UploadedImage(UploadedFile): class UploadedImage(UploadedFile):
@classmethod @classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any: def _validate(cls, v: Any, info: ValidationInfo) -> Any:
@@ -151,10 +161,6 @@ class UserFilterSchema(FilterSchema):
return value return value
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema): class FamilyGodfatherSchema(Schema):
godfather: int godfather: int
godchild: int godchild: int
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
Alpine.directive( Alpine.directive(
"limited-choices", "limited-choices",
(el, { expression }, { evaluateLater, effect }) => { (el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression); const getMaxChoices = evaluateLater<string>(expression);
let maxChoices: number; let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from( const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"), el.querySelectorAll("input[type='checkbox']"),
@@ -54,7 +54,7 @@ export function limitedChoices(Alpine: AlpineType) {
}); });
} }
effect(() => { effect(() => {
getMaxChoices((value: string) => { getMaxChoices((value) => {
const previousValue = maxChoices; const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10); maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) { if (maxChoices < previousValue) {
+19 -7
View File
@@ -6,10 +6,15 @@
* for more efficient tree-shaking and gzip compression. * for more efficient tree-shaking and gzip compression.
*/ */
// Must be loaded before Apline
import htmx from "htmx.org";
import "htmx.org/dist/ext/hx-alpine-compat.js";
import "htmx.org/dist/ext/hx-prompt.js";
import "htmx.org/dist/ext/hx-download.js";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices"; import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage"; import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar"; import { default as navbar } from "#core:core/navbar";
@@ -43,14 +48,21 @@ polyfillCountryFlagEmojis();
/** /**
* HTMX * HTMX
*/ */
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => { document.body.addEventListener(
event.detail.target.ariaBusy = true; "htmx:before:request" as keyof HTMLElementEventMap,
}); (event) => {
(event as CustomEvent).detail.ctx.target.ariaBusy = true;
},
);
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => { document.body.addEventListener(
event.detail.target.ariaBusy = null; "htmx:before:swap" as keyof HTMLElementEventMap,
}); (event) => {
(event as CustomEvent).detail.ctx.target.ariaBusy = null;
},
);
htmx.config.transitions = true;
Object.assign(window, { htmx }); Object.assign(window, { htmx });
/** /**
@@ -4,9 +4,9 @@ import type {
TomLoadCallback, TomLoadCallback,
TomOption, TomOption,
TomSettings, TomSettings,
} from "tom-select/dist/types/types"; } from "tom-select/src/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/src/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts"; import { inheritHtmlElement } from "#core:utils/web-components";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") { export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [ static observedAttributes = [
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
"max", "max",
"min-characters-for-search", "min-characters-for-search",
]; ];
public widget: TomSelect; public widget!: TomSelect;
protected minCharNumberForSearch = 0; protected minCharNumberForSearch = 0;
protected delay: number | null = null; protected delay: number | null = null;
@@ -24,8 +24,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
protected attributeChangedCallback( protected attributeChangedCallback(
name: string, name: string,
_oldValue?: string, _oldValue: string,
newValue?: string, newValue: string,
) { ) {
switch (name) { switch (name) {
case "delay": { case "delay": {
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
persist: false, persist: false,
maxItems: this.node.multiple ? this.max : 1, maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true, closeAfterSelect: true,
loadThrottle: this.delay, loadThrottle: this.delay ?? undefined,
placeholder: this.placeholder, placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: { render: {
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
} }
export abstract class AjaxSelect extends AutoCompleteSelectBase { export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null; protected filter?: (items: TomOption[]) => TomOption[];
protected minCharNumberForSearch = 2; protected minCharNumberForSearch = 2;
/** /**
* A cache of researches that have been made using this input. * A cache of researches that have been made using this input.
@@ -1,11 +1,12 @@
// @ts-expect-error TS2882
import "tom-select/dist/css/tom-select.default.css"; import "tom-select/dist/css/tom-select.default.css";
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/src/utils";
import { import {
AjaxSelect, AjaxSelect,
AutoCompleteSelectBase, AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base.ts"; } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
import { import {
type GroupSchema, type GroupSchema,
groupSearchGroup, groupSearchGroup,
@@ -1,16 +1,14 @@
// @ts-expect-error 2307
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde // biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css"; import "easymde/src/css/easymde.css";
import { markdown } from "@ae_utbm/aemark";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE // biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror"; import type CodeMirror from "codemirror"; // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { import { type UploadUploadImageErrors, uploadUploadImage } from "#openapi";
markdownRenderMarkdown,
type UploadUploadImageErrors,
uploadUploadImage,
} from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => { const loadEasyMde = (textarea: HTMLTextAreaElement) => {
const easymde = new EasyMDE({ const easymde = new EasyMDE({
@@ -25,11 +23,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
file: file, file: file,
}, },
}); });
if (!response.response.ok) { if (response.response !== undefined && !response.response.ok) {
if (response.response.status === 422) { if (response?.response.status === 422) {
onError( onError(
(response.error as UploadUploadImageErrors[422]).detail (response.error as UploadUploadImageErrors[422]).detail
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error) .map((err) => err.ctx.error)
.join(" ; "), .join(" ; "),
); );
} else if (response.response.status === 403) { } else if (response.response.status === 403) {
@@ -39,6 +37,10 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
} }
return; return;
} }
if (response.data === undefined) {
// this can't happen, it's just for the type checker to know
return;
}
onSuccess(response.data.href); onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image // Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url) // Without this, you get ![](url) instead of ![name](url)
@@ -58,22 +60,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
easymde.codemirror.replaceSelection("\n"); easymde.codemirror.replaceSelection("\n");
}, },
previewRender: (plainText: string, preview: MarkdownInput) => { previewRender: (plainText) => markdown(plainText),
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func(plainText, preview);
return null;
}, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
imageTexts: { imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."), sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
@@ -222,9 +209,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
const submits: HTMLInputElement[] = Array.from( const submits: HTMLInputElement[] = Array.from(
textarea.closest("form").querySelectorAll('input[type="submit"]'), (textarea.closest("form") as HTMLFormElement).querySelectorAll(
'input[type="submit"]',
),
); );
const parentDiv = textarea.parentElement.parentElement; const parentDiv = textarea.parentElement?.parentElement as HTMLElement;
function checkMarkdownInput(event: Event) { function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string // an attribute is null if it does not exist, else a string
@@ -249,6 +238,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}; };
@registerComponent("markdown-input") @registerComponent("markdown-input")
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
class MarkdownInput extends inheritHtmlElement("textarea") { class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
@@ -2,7 +2,7 @@ import {
type InheritedHtmlElement, type InheritedHtmlElement,
inheritHtmlElement, inheritHtmlElement,
registerComponent, registerComponent,
} from "#core:utils/web-components.ts"; } from "#core:utils/web-components";
/** /**
* ElementOnce web components * ElementOnce web components
@@ -28,7 +28,7 @@ export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
clearNode() { clearNode() {
while (this.firstChild) { while (this.firstChild) {
this.removeChild(this.lastChild); this.removeChild(this.lastChild as ChildNode);
} }
} }
@@ -130,7 +130,7 @@ startObserver(observer);
export class LinkOnce extends elementOnce("link") { export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string { getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part // We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`; return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`;
} }
} }
@@ -142,6 +142,6 @@ export class LinkOnce extends elementOnce("link") {
export class ScriptOnce extends inheritHtmlElement("script") { export class ScriptOnce extends inheritHtmlElement("script") {
getElementQuerySelector(): string { getElementQuerySelector(): string {
// We get href from node.attributes instead of node.src to avoid getting the domain part // We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`; return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`;
} }
} }
@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
@registerComponent("nfc-input") @registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") { export class NfcInput extends inheritHtmlElement("input") {
@@ -26,9 +26,11 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card")); window.alert(gettext("Unsupported NFC card"));
}); });
ndef.addEventListener("reading", (event: NDEFReadingEvent) => { ndef.addEventListener("reading", (event) => {
this.removeAttribute("scan"); this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase(); this.node.value = (event as NDEFReadingEvent).serialNumber
.replace(/:/g, "")
.toUpperCase();
/* Auto submit form, we need another button to not trigger our previously defined click event */ /* Auto submit form, we need another button to not trigger our previously defined click event */
const submit = document.createElement("button"); const submit = document.createElement("button");
this.node.appendChild(submit); this.node.appendChild(submit);
@@ -1,6 +1,6 @@
import { html, render } from "lit-html"; import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
@registerComponent("ui-tab") @registerComponent("ui-tab")
export class Tab extends HTMLElement { export class Tab extends HTMLElement {
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
} }
if (name === "title") { if (name === "title") {
this.description = newValue; this.description = newValue ?? "";
} }
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true })); this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
} }
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
@registerComponent("ui-tab-group") @registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement { export class TabGroup extends HTMLElement {
private node: HTMLDivElement; private node!: HTMLDivElement;
connectedCallback() { connectedCallback() {
this.node = document.createElement("div"); this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow"); this.node.classList.add("tabs", "shadow");
this.appendChild(this.node); this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event: CustomEvent) => { this.addEventListener("ui-tab-activated", (event) => {
const target = event.detail as Tab; const target = (event as CustomEvent).detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) { for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) { if (tab !== target) {
tab.setActive(false); tab.setActive(false);
@@ -26,19 +26,23 @@ document.addEventListener("alpine:init", () => {
* `counter/templates/counter/product_form.jinja` * `counter/templates/counter/product_form.jinja`
*/ */
Alpine.data("dynamicFormSet", (config?: Config) => ({ Alpine.data("dynamicFormSet", (config?: Config) => ({
formContainer: undefined as unknown as HTMLElement,
nbForms: 0,
template: undefined as unknown as HTMLTemplateElement,
init() { init() {
this.formContainer = this.$refs.formContainer as HTMLElement; this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number; this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement; this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form"; const prefix = config?.prefix ?? "form";
this.$root (
.querySelector(`#id_${prefix}-TOTAL_FORMS`) this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement
.setAttribute(":value", "nbForms"); ).setAttribute(":value", "nbForms");
}, },
addForm() { addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true)); this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild; const newForm = this.formContainer.lastElementChild as Element;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll( const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea", "input, select, textarea",
); );
@@ -59,7 +63,7 @@ document.addEventListener("alpine:init", () => {
this.nbForms -= 1; this.nbForms -= 1;
// adjust the id of remaining forms // adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) { for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i]; const form = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll( const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea", "input, select, textarea",
); );
+6 -3
View File
@@ -1,5 +1,5 @@
function showMenu() { function showMenu() {
const navbar = document.getElementById("navbar-content"); const navbar = document.getElementById("navbar-content") as HTMLElement;
const current = navbar.getAttribute("mobile-display"); const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden"); navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
} }
@@ -20,9 +20,12 @@ function navbarInit() {
item.removeAttribute("open"); item.removeAttribute("open");
} }
}); });
item.addEventListener("click", (event: MouseEvent) => { item.addEventListener("click", (event) => {
// Don't close when clicking on desktop mode // Don't close when clicking on desktop mode
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) { if (
(event.target as HTMLElement).nodeName !== "SUMMARY" ||
(event as MouseEvent).detail === 0
) {
return; return;
} }
@@ -1,3 +1,4 @@
// @ts-expect-error 2307 this dependency does exist, but it's a little bit wacky
import clip from "@arendjr/text-clipper"; import clip from "@arendjr/text-clipper";
/* /*
+8 -7
View File
@@ -70,15 +70,16 @@ function createTooltip(element: HTMLElement) {
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) { function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content // Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status); tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip"); tooltip.innerText = element.getAttribute("tooltip") as string;
for (const attributes of [ for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] }, { src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] }, { src: "tooltip-id", dst: "id", default: [] },
]) { ]) {
const populated = attributes.default; const populated = attributes.default;
if (element.hasAttribute(attributes.src)) { const attr = element.getAttribute(attributes.src);
populated.push(...element.getAttribute(attributes.src).split(" ")); if (attr !== null) {
populated.push(...attr.split(" "));
} }
tooltip.setAttribute(attributes.dst, populated.join(" ")); tooltip.setAttribute(attributes.dst, populated.join(" "));
} }
@@ -93,7 +94,7 @@ function getTooltip(element: HTMLElement) {
return tooltip; return tooltip;
} }
function tooltipMouseover(event: MouseEvent) { function tooltipMouseover(event: Event) {
// We get the closest tooltip to have a consistent behavior // We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element // when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -111,7 +112,7 @@ function tooltipMouseover(event: MouseEvent) {
}); });
} }
function tooltipMouseout(event: MouseEvent) { function tooltipMouseout(event: Event) {
// We get the closest tooltip to have a consistent behavior // We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element // when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -141,7 +142,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
} }
} else if (tooltips.has(target)) { } else if (tooltips.has(target)) {
// Remove corresponding tooltip // Remove corresponding tooltip
tooltips.get(target).remove(); tooltips.get(target)?.remove();
tooltips.delete(target); tooltips.delete(target);
} }
} }
@@ -163,7 +164,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
continue; continue;
} }
if (tooltips.has(target)) { if (tooltips.has(target)) {
tooltips.get(target).remove(); tooltips.get(target)?.remove();
tooltips.delete(target); tooltips.delete(target);
} }
} }
-1
View File
@@ -55,7 +55,6 @@ declare interface NDEFRecordInit {
// biome-ignore lint/style/useNamingConvention: this is the official API name // biome-ignore lint/style/useNamingConvention: this is the official API name
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit; declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window { interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name // biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFReader: NDEFReader; NDEFReader: NDEFReader;
+17 -22
View File
@@ -1,20 +1,13 @@
import cytoscape, { import cytoscape, { type ElementDefinition, type Singular } from "cytoscape";
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu"; import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay"; import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi"; import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay); cytoscape.use(klay);
cytoscape.use(cxtmenu); cytoscape.use(cxtmenu);
type GraphData = ( type GraphData = { data: UserProfileSchema | { source: number; target: number } }[];
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() { function isMobile() {
return window.innerWidth < 500; return window.innerWidth < 500;
@@ -27,10 +20,8 @@ async function getGraphData(
): Promise<GraphData> { ): Promise<GraphData> {
const data = ( const data = (
await familyGetFamilyGraph({ await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId, path: { user_id: userId },
},
query: { query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth, godfathers_depth: godfathersDepth,
@@ -39,6 +30,10 @@ async function getGraphData(
}, },
}) })
).data; ).data;
if (data === undefined) {
console.error("Family graph request failed");
return [];
}
return [ return [
...data.users.map((user) => { ...data.users.map((user) => {
return { data: user }; return { data: user };
@@ -155,7 +150,7 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n
{ {
content: '<i class="fa fa-external-link fa-2x"></i>', content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => { select: (el) => {
window.open(el.data().profile_url, "_blank").focus(); window.open(el.data().profile_url, "_blank")?.focus();
}, },
}, },
@@ -195,12 +190,12 @@ document.addEventListener("alpine:init", () => {
godfathersDepth: 0, godfathersDepth: 0,
godchildrenDepth: 0, godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as cytoscape.Core, graph: undefined as unknown as cytoscape.Core,
graphData: {}, graphData: {} as GraphData,
isZoomEnabled: !isMobile(), isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) { getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop), 10); const value = Number.parseInt(initialUrlParams.get(prop) as string, 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth; return defaultDepth;
} }
@@ -223,8 +218,8 @@ document.addEventListener("alpine:init", () => {
await delayedFetch(); await delayedFetch();
}); });
} }
this.$watch("reverse", async (value: number) => { this.$watch("reverse", async (newValue, _oldValue) => {
updateQueryString("reverse", value.toString(), History.Replace); updateQueryString("reverse", newValue.toString(), History.Replace);
await this.reverseGraph(); await this.reverseGraph();
}); });
this.$watch("graphData", async () => { this.$watch("graphData", async () => {
@@ -259,9 +254,9 @@ document.addEventListener("alpine:init", () => {
}, },
async reverseGraph() { async reverseGraph() {
this.graph.elements((el: NodeSingular) => { this.graph
el.position({ x: -el.position().x, y: -el.position().y }); .elements()
}); .positions((el, _) => ({ x: -el.position().x, y: -el.position().y }));
this.graph.center(this.graph.elements()); this.graph.center(this.graph.elements());
}, },
+2 -2
View File
@@ -5,9 +5,9 @@ interface AlertParams {
export class AlertMessage { export class AlertMessage {
public open: boolean; public open: boolean;
public success: boolean; public success!: boolean;
public content: string; public content: string;
private timeoutId?: number; private timeoutId: number | null;
private readonly defaultDuration: number; private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) { constructor(params?: { defaultDuration: number }) {
+8 -5
View File
@@ -37,6 +37,10 @@ export const paginated = async <T>(
queryParams.query.page = 1; queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data; const firstPage = (await endpoint(queryParams)).data;
if (firstPage === undefined) {
console.error(`Request to "${options?.url}" failed`);
return [];
}
const results = firstPage.results; const results = firstPage.results;
const nbElements = firstPage.count; const nbElements = firstPage.count;
@@ -45,9 +49,9 @@ export const paginated = async <T>(
if (nbPages > 1) { if (nbPages > 1) {
const promises: Promise<T[]>[] = []; const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) { for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams); const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>;
nextPage.query.page = i; nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data.results)); promises.push(endpoint(nextPage).then((res) => res.data?.results ?? []));
} }
results.push(...(await Promise.all(promises)).flat()); results.push(...(await Promise.all(promises)).flat());
} }
@@ -61,8 +65,7 @@ interface Request extends TDataShape {
interface InterceptorOptions { interface InterceptorOptions {
url: string; url: string;
} }
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>, options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>; ) => RequestResult<unknown, unknown, ThrowOnError>;
@@ -71,7 +74,7 @@ type GenericEndpoint = <ThrowOnError extends boolean = false>(
**/ **/
export const makeUrl = async (endpoint: GenericEndpoint) => { export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = ""; let url = "";
const interceptor = (_request: undefined, options: InterceptorOptions) => { const interceptor = (_request: Request, options: InterceptorOptions) => {
url = options.url; url = options.url;
throw new Error("We don't want to send the request"); throw new Error("We don't want to send the request");
}; };
+12 -9
View File
@@ -7,14 +7,14 @@ interface StringifyOptions<T extends object> {
titleRow?: readonly string[]; titleRow?: readonly string[];
} }
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) { function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[]; const path = key.split(".");
let res = obj[path.shift() as keyof T]; let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined;
for (const node of path) { for (const node of path) {
if (res === null) { if (res === undefined) {
break; break;
} }
res = res[node]; res = res[node] as { [key: string]: unknown } | undefined;
} }
return res; return res;
} }
@@ -29,18 +29,21 @@ function sanitizeCell(content: string): string {
} }
export const csv = { export const csv = {
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => { stringify: <T extends { [key: string]: unknown }>(
const columns = options.columns; objs: T[],
options?: StringifyOptions<T>,
) => {
const columns = options?.columns;
const content = objs const content = objs
.map((obj) => { .map((obj) => {
return columns return (columns ?? [])
.map((col) => { .map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString()); return sanitizeCell((getNested(obj, col) ?? "").toString());
}) })
.join(","); .join(",");
}) })
.join("\n"); .join("\n");
if (!options.titleRow) { if (!options?.titleRow) {
return content; return content;
} }
const firstRow = options.titleRow.map(sanitizeCell).join(","); const firstRow = options.titleRow.map(sanitizeCell).join(",");
+3 -2
View File
@@ -29,6 +29,7 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap> export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement { extends HTMLElement {
readonly inheritedTagName: K; readonly inheritedTagName: K;
// readonly initializedAttribute: "component-initialized";
node: HTMLElementTagNameMap[K]; node: HTMLElementTagNameMap[K];
} }
@@ -47,8 +48,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K> implements InheritedHtmlElement<K>
{ {
readonly inheritedTagName = tagName; readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized"; readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K]; node!: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) { connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice // When nesting inherited elements, we might trigger the wrapping twice
+4
View File
@@ -46,6 +46,10 @@ details.accordion>.accordion-content {
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
overflow: hidden; overflow: hidden;
@media screen and (max-width: 600px) {
padding: .75em 1.5em;
}
} }
@mixin animation($selector) { @mixin animation($selector) {
+6 -1
View File
@@ -29,7 +29,12 @@
align-items: center; align-items: center;
gap: 20px; gap: 20px;
&.clickable:hover { &:disabled {
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
background-color: darken($primary-neutral-light-color, 5%); background-color: darken($primary-neutral-light-color, 5%);
} }
+1 -2
View File
@@ -23,7 +23,7 @@
border-radius: 5px; border-radius: 5px;
color: black; color: black;
&:hover { &:not(.link-like):not(:disabled):hover {
background: hsl(0, 0%, 83%); background: hsl(0, 0%, 83%);
} }
} }
@@ -141,7 +141,6 @@ form {
display: block; display: block;
margin: calc(var(--nf-input-size) * 1.5) auto 10px; margin: calc(var(--nf-input-size) * 1.5) auto 10px;
line-height: 1; line-height: 1;
white-space: nowrap;
.fields-centered { .fields-centered {
padding: 10px 10px 0; padding: 10px 10px 0;
+2 -2
View File
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
justify-content: center; justify-content: center;
} }
>.button { a.button {
box-sizing: border-box; box-sizing: border-box;
height: 35px; height: 35px;
background-color: transparent; background-color: transparent;
@@ -139,7 +139,7 @@ $background-color-hovered: #283747;
font-size: .9em; font-size: .9em;
width: 120px; width: 120px;
&:hover { &:not(.link-like):not(:disabled):hover {
background-color: $background-color-hovered; background-color: $background-color-hovered;
} }
} }
+4
View File
@@ -4,6 +4,10 @@
<div id="page"> <div id="page">
<h3>{% trans %}404, Not Found{% endtrans %}</h3> <h3>{% trans %}404, Not Found{% endtrans %}</h3>
<blockquote>
{% trans %}Impossible, perhaps the archives are incomplete{% endtrans %}
</blockquote>
</div> </div>
{% endblock %} {% endblock %}
+3 -8
View File
@@ -22,14 +22,9 @@
</form> </form>
<ul class="bars"> <ul class="bars">
{% cache 100 "counters_activity" %} {% cache 100 "counters_activity" %}
{# The sith has no periodic tasks manager {# It would be cleaner to handle the timeout with django-celery-beat,
and using cron jobs would be way too overkill here. but doing it here is simpler and less error-prone #}
Thus the barmen timeout is handled in the only place that {% do Counter.objects.filter(type="BAR").handle_timeout() %}
is loaded on every page : the header bar.
However, let's be clear : this has nothing to do here.
It's' merely a contrived workaround that should
replaced by a proper task manager as soon as possible. #}
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
{% endcache %} {% endcache %}
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %} {% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
<li> <li>
+1 -1
View File
@@ -10,7 +10,7 @@
<template x-for="(message, index) in $notifications.getAll()"> <template x-for="(message, index) in $notifications.getAll()">
<div class="alert" :class="`alert-${message.tag}`" x-transition> <div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span> <span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)"> <span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</span> </span>
</div> </div>
@@ -31,7 +31,7 @@
{% if is_fragment %} {% if is_fragment %}
hx-post="{{ action }}" hx-post="{{ action }}"
hx-target="#content" hx-target="#content"
hx-swap="outerHtml" hx-swap="innerHTML"
{% endif %} {% endif %}
>{% trans %}Confirm{% endtrans %}</button> >{% trans %}Confirm{% endtrans %}</button>
@@ -39,7 +39,7 @@
{% if is_fragment %} {% if is_fragment %}
hx-get="{{ previous }}" hx-get="{{ previous }}"
hx-target="#content" hx-target="#content"
hx-swap="outerHtml" hx-swap="innerHTML"
{% else %} {% else %}
action="window.history.back()" action="window.history.back()"
{% endif %} {% endif %}
+2 -2
View File
@@ -39,13 +39,13 @@
<p><button <p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}" hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
hx-target="#content" hx-target="#content"
hx-swap="outerHtml" hx-swap="innerHTML"
>{% trans %}Moderate{% endtrans %}</button> - >{% trans %}Moderate{% endtrans %}</button> -
{% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %} {% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %}
<button <button
hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}" hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}"
hx-target="#file-{{ loop.index }}" hx-target="#file-{{ loop.index }}"
hx-swap="outerHtml" hx-swap="innerHTML"
>{% trans %}Delete{% endtrans %}</button></p> >{% trans %}Delete{% endtrans %}</button></p>
</div> </div>
{% endfor %} {% endfor %}
@@ -1,6 +1,6 @@
<form <form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}" hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disabled-elt="find input[type='submit']" hx-disable="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }" hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
> >
{% for message in messages %} {% for message in messages %}
+3 -1
View File
@@ -20,12 +20,14 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from typing import Callable from typing import TYPE_CHECKING, Callable
import honeypot.templatetags.honeypot as honeypot_filters import honeypot.templatetags.honeypot as honeypot_filters
from django.template.loader import render_to_string from django.template.loader import render_to_string
from jinja2 import Environment, nodes from jinja2 import Environment, nodes
from jinja2.ext import Extension from jinja2.ext import Extension
if TYPE_CHECKING:
from jinja2.parser import Parser from jinja2.parser import Parser
+4 -2
View File
@@ -23,15 +23,17 @@
# #
import datetime import datetime
from typing import TYPE_CHECKING
import phonenumbers import phonenumbers
from aemark import markdown as md
from django import template from django import template
from django.forms import BoundField
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ngettext from django.utils.translation import ngettext
from core.markdown import markdown as md if TYPE_CHECKING:
from django.forms import BoundField
register = template.Library() register = template.Library()
+9 -12
View File
@@ -18,6 +18,7 @@ from smtplib import SMTPException
import freezegun import freezegun
import pytest import pytest
from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@@ -34,7 +35,6 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Club from club.models import Club
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester from core.utils import get_last_promo, get_semester_code, get_start_of_semester
from core.views import AllowFragment from core.views import AllowFragment
@@ -200,31 +200,28 @@ class TestUserLogin:
[ [
( (
"[nom du lien](page://nomDeLaPage)", "[nom du lien](page://nomDeLaPage)",
'<a href="/page/nomDeLaPage/">nom du lien</a>', '<a href="/page/nomDeLaPage">nom du lien</a>',
), ),
("__texte__", "<u>texte</u>"), ("__texte__", "<u>texte</u>"),
("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"), ("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"),
( (
'![tst_alt](/img.png?50% "tst_title")', '![tst_alt](/img.png?50% "tst_title")',
'<img src="/img.png" alt="tst_alt" title="tst_title" style="width:50%;" />', '<img src="/img.png" style="width:50%" alt="tst_alt" title="tst_title" />',
),
(
"[texte](page://tst-page)",
'<a href="/page/tst-page/">texte</a>',
), ),
("[texte](page://tst-page)", '<a href="/page/tst-page">texte</a>'),
( (
"![](/img.png?50x450)", "![](/img.png?50x450)",
'<img src="/img.png" alt="" style="width:50px;height:450px;" />', '<img src="/img.png" style="width:50px;height:450px" alt="" />',
), ),
("![](/img.png)", '<img src="/img.png" alt="" />'), ("![](/img.png)", '<img src="/img.png" alt="" />'),
( (
"![](/img.png?50%x120%)", "![](/img.png?50%x120%)",
'<img src="/img.png" alt="" style="width:50%;height:120%;" />', '<img src="/img.png" style="width:50%;height:120%" alt="" />',
), ),
("![](/img.png?50px)", '<img src="/img.png" alt="" style="width:50px;" />'), ("![](/img.png?50px)", '<img src="/img.png" style="width:50px" alt="" />'),
( (
"![](/img.png?50pxx120%)", "![](/img.png?50pxx120%)",
'<img src="/img.png" alt="" style="width:50px;height:120%;" />', '<img src="/img.png" style="width:50px;height:120%" alt="" />',
), ),
# when the image dimension has a wrong format, don't touch the url # when the image dimension has a wrong format, don't touch the url
("![](/img.png?50pxxxxxxxx)", '<img src="/img.png?50pxxxxxxxx" alt="" />'), ("![](/img.png?50pxxxxxxxx)", '<img src="/img.png?50pxxxxxxxx" alt="" />'),
@@ -350,7 +347,7 @@ http://git.an
<p><a href="http://git.an">http://git.an</a></p> <p><a href="http://git.an">http://git.an</a></p>
<h1>Swag</h1> <h1>Swag</h1>
<p>&lt;guy&gt;Bibou&lt;/guy&gt;</p> <p>&lt;guy&gt;Bibou&lt;/guy&gt;</p>
<p>&lt;script&gt;alert('Guy');&lt;/script&gt;</p> &lt;script&gt;alert('Guy');&lt;/script&gt;
""" """
assertInHTML(expected, response.text) assertInHTML(expected, response.text)
+5 -2
View File
@@ -1,11 +1,12 @@
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import freezegun import freezegun
import pytest import pytest
from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
@@ -13,9 +14,11 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User from core.models import AnonymousUser, Page, PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
class TestEditPage: class TestEditPage:
+24 -2
View File
@@ -141,6 +141,22 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search")) response = self.client.get(reverse("core:search"))
assert response.status_code == 200 assert response.status_code == 200
def test_search_with_whitelist_unique(self):
"""Test that when a user has a whitelist and appears in the results,
it appears only once.
This is a regression test (cf #1463)
"""
user = subscriber_user.make(is_viewable=False)
user.whitelisted_users.add(
*subscriber_user.make(_quantity=4, _bulk_create=True)
)
self.client.force_login(user)
response = self.client.get(
reverse("core:search", query={"query": user.last_name})
)
assert response.context_data["users"] == [user]
@pytest.mark.django_db @pytest.mark.django_db
def test_user_account_not_found(client: Client): def test_user_account_not_found(client: Client):
@@ -200,7 +216,11 @@ class TestFilterInactive(TestCase):
] ]
sale_recipe.make(customer=cls.users[3].customer, date=time_active) sale_recipe.make(customer=cls.users[3].customer, date=time_active)
baker.make( baker.make(
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter Refilling,
customer=cls.users[4].customer,
date=time_active,
counter=counter,
amount=1,
) )
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive) sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
@@ -455,7 +475,9 @@ def test_user_preferences(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_user_stats(client: Client): def test_user_stats(client: Client):
user = subscriber_user.make() user = subscriber_user.make()
baker.make(Refilling, customer=user.customer, amount=99999) baker.make(
Refilling, customer=user.customer, amount=settings.SITH_ACCOUNT_MAX_MONEY
)
bars = [b[0] for b in settings.SITH_COUNTER_BARS] bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make( baker.make(
Permanency, Permanency,
+5 -3
View File
@@ -17,16 +17,18 @@ from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Final from typing import TYPE_CHECKING, Final
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
RED_PIXEL_PNG: Final[bytes] = ( RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
+4 -1
View File
@@ -13,7 +13,7 @@
# #
# #
import mimetypes import mimetypes
from pathlib import Path from typing import TYPE_CHECKING
from urllib.parse import quote, urljoin from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
@@ -48,6 +48,9 @@ from core.views.widgets.ajax_select import (
) )
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from pathlib import Path
def send_raw_file(path: Path) -> HttpResponse: def send_raw_file(path: Path) -> HttpResponse:
"""Send a file located in the MEDIA_ROOT """Send a file located in the MEDIA_ROOT
+4 -1
View File
@@ -22,8 +22,8 @@
# #
import re import re
from copy import copy from copy import copy
from datetime import date, datetime
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
from django import forms from django import forms
@@ -59,6 +59,9 @@ from core.views.widgets.ajax_select import (
) )
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from datetime import date, datetime
# Widgets # Widgets
+7 -2
View File
@@ -22,11 +22,11 @@
# #
# #
from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import F from django.db.models import F
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.views.generic import ListView, TemplateView from django.views.generic import ListView, TemplateView
@@ -34,6 +34,10 @@ from club.models import Club
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UserFilterSchema from core.schemas import UserFilterSchema
if TYPE_CHECKING:
from django.db.models.query import QuerySet
from django.http import HttpRequest
class NotificationList(LoginRequiredMixin, ListView): class NotificationList(LoginRequiredMixin, ListView):
model = Notification model = Notification
@@ -65,6 +69,7 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query) UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user)) .filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True)) .order_by(F("last_login").desc(nulls_last=True))
.distinct()
) )
clubs = list(Club.objects.filter(name__icontains=query)[:5]) clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs} return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
+5 -3
View File
@@ -1,14 +1,16 @@
import copy import copy
import inspect import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.views import View from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin from django.views.generic.base import ContextMixin, TemplateResponseMixin
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import SafeString
class TabedViewMixin(View): class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template.""" """Basic functions for displaying tabs in the template."""
+4 -1
View File
@@ -27,6 +27,7 @@ from datetime import timedelta
# This file contains all the views that concern the user model # This file contains all the views that concern the user model
from operator import itemgetter from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login, views from django.contrib.auth import login, views
@@ -43,7 +44,6 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic import ( from django.views.generic import (
@@ -73,6 +73,9 @@ from counter.models import Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class SithLoginView(views.LoginView): class SithLoginView(views.LoginView):
"""The login View.""" """The login View."""
+7 -4
View File
@@ -1,15 +1,18 @@
from collections.abc import Collection from typing import TYPE_CHECKING, Any
from typing import Any
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Model, QuerySet
from django.forms import Select, SelectMultiple from django.forms import Select, SelectMultiple
from ninja import ModelSchema
from pydantic import TypeAdapter from pydantic import TypeAdapter
from core.models import Group, SithFile, User from core.models import Group, SithFile, User
from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema
if TYPE_CHECKING:
from collections.abc import Collection
from django.db.models import Model, QuerySet
from ninja import ModelSchema
class AutoCompleteSelectMixin: class AutoCompleteSelectMixin:
component_name = "autocomplete-select" component_name = "autocomplete-select"
+50 -4
View File
@@ -1,22 +1,68 @@
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.core import checks
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.functional import cached_property
class CurrencyField(models.DecimalField): class CurrencyField(models.DecimalField):
"""Custom database field used for currency.""" """Custom database field used for currency."""
def __init__(self, *args, **kwargs): def __init__(
kwargs["max_digits"] = 12 self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs
kwargs["decimal_places"] = 2 ):
super().__init__(*args, **kwargs) kwargs.update({"max_digits": 12, "decimal_places": 2})
self.min_value = min_value
self.max_value = max_value
super().__init__(verbose_name, name, **kwargs)
def to_python(self, value): def to_python(self, value):
if value is None: if value is None:
return None return None
return super().to_python(value).quantize(Decimal("0.01")) return super().to_python(value).quantize(Decimal("0.01"))
@cached_property
def validators(self):
res = []
if self.max_value:
res.append(MaxValueValidator(self.max_value))
if self.min_value:
res.append(MinValueValidator(self.min_value))
return [*super().validators, *res]
def check(self, **kwargs): # pragma: no cover
# this is executed during runserver, but won't run in prod
errors = super().check(**kwargs)
for name, val in ("min_value", self.min_value), ("max_value", self.max_value):
if not val:
continue
try:
float(val)
except ValueError:
errors.append(
checks.Error(
f"CurrencyField.{name} must be a valid float",
obj=self,
id="sith.E001",
)
)
return errors
def formfield(self, **kwargs):
return super().formfield(
**{"min_value": self.min_value, "max_value": self.max_value, **kwargs}
)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.min_value is not None:
kwargs["min_value"] = self.min_value
if self.max_value is not None:
kwargs["max_value"] = self.max_value
return name, path, args, kwargs
if settings.TESTING: if settings.TESTING:
from model_bakery import baker from model_bakery import baker
+93 -39
View File
@@ -3,12 +3,15 @@ import math
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from typing import TYPE_CHECKING, ClassVar
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule from django_celery_beat.models import ClockedSchedule
@@ -17,6 +20,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User, UserQuerySet from core.models import User, UserQuerySet
from core.views import LoginForm
from core.views.forms import ( from core.views.forms import (
FutureDateTimeField, FutureDateTimeField,
NFCTextInput, NFCTextInput,
@@ -37,6 +41,7 @@ from counter.models import (
Customer, Customer,
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Permanency,
Price, Price,
Product, Product,
ProductFormula, ProductFormula,
@@ -53,6 +58,9 @@ from counter.widgets.ajax_select import (
AutoCompleteSelectProduct, AutoCompleteSelectProduct,
) )
if TYPE_CHECKING:
from django.http import HttpRequest
class BillingInfoForm(forms.ModelForm): class BillingInfoForm(forms.ModelForm):
class Meta: class Meta:
@@ -91,30 +99,18 @@ class StudentCardForm(forms.ModelForm):
class GetUserForm(forms.Form): class GetUserForm(forms.Form):
"""The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view, """Find a user to show its click page."""
reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
some nickname, first name, or last name (TODO)
"""
code = forms.CharField( code = forms.CharField(
label="Code", label="Code",
max_length=StudentCard.UID_SIZE, max_length=StudentCard.UID_SIZE,
required=False, required=False,
widget=NFCTextInput, widget=NFCTextInput(attrs={"autofocus": True}),
) )
id = forms.CharField( id = forms.CharField(
label=_("Select user"), label=_("Select user"), widget=AutoCompleteSelectUser, required=False
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
) )
def as_p(self):
self.fields["code"].widget.attrs["autofocus"] = True
return super().as_p()
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
customer = None customer = None
@@ -136,11 +132,41 @@ class GetUserForm(forms.Form):
if customer is None or not customer.can_buy: if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found")) raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = customer.user.id cleaned_data["user_id"] = customer.user_id
cleaned_data["user"] = customer.user cleaned_data["user"] = customer.user
return cleaned_data return cleaned_data
class CounterLoginForm(LoginForm):
"""LoginForm to log a barman in a counter.
To be able to log in a counter, a user must :
- be part of the sellers of the given counter
- not being already logged in any counter
"""
def __init__(self, *args, request: HttpRequest, counter: Counter, **kwargs):
super().__init__(*args, **kwargs)
self.counter = counter
self.request = request
def confirm_login_allowed(self, user: User):
super().confirm_login_allowed(user)
if not self.counter.sellers.contains(user):
raise ValidationError(
message=_("You are not a barman of this counter."), code="not_barman"
)
if Permanency.objects.filter(end=None, user=user).exists():
if user in self.request.barmen:
message = _("You are already logged in this counter.")
elif user in self.counter.barmen_list:
message = _("You are already logged in another counter.")
else:
message = _("You are already logged on another device")
raise ValidationError(message=message, code="already_logged_in")
class RefillForm(forms.ModelForm): class RefillForm(forms.ModelForm):
allowed_refilling_methods = [ allowed_refilling_methods = [
Refilling.PaymentMethod.CASH, Refilling.PaymentMethod.CASH,
@@ -149,18 +175,19 @@ class RefillForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
amount = forms.FloatField(
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
)
class Meta: class Meta:
model = Refilling model = Refilling
fields = ["amount", "payment_method"] fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect} widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs): def __init__(
self, *args, counter: Counter, operator: User, customer: Customer, **kwargs
):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
max_value = settings.SITH_ACCOUNT_MAX_MONEY - customer.amount
# server-side max_value validation is done by Refilling.clean
self.fields["amount"].widget.attrs["max"] = max_value
self.fields["payment_method"].choices = ( self.fields["payment_method"].choices = (
method method
for method in self.fields["payment_method"].choices for method in self.fields["payment_method"].choices
@@ -168,6 +195,9 @@ class RefillForm(forms.ModelForm):
) )
if self.fields["payment_method"].initial not in self.allowed_refilling_methods: if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0] self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
self.instance.counter = counter
self.instance.operator = operator
self.instance.customer = customer
class CounterEditForm(forms.ModelForm): class CounterEditForm(forms.ModelForm):
@@ -409,6 +439,7 @@ class ProductForm(forms.ModelForm):
"club", "club",
"limit_age", "limit_age",
"tray", "tray",
"clic_limit",
"archived", "archived",
] ]
help_texts = { help_texts = {
@@ -540,16 +571,7 @@ class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True) quantity = forms.IntegerField(min_value=1, required=True)
price_id = forms.IntegerField(min_value=0, required=True) price_id = forms.IntegerField(min_value=0, required=True)
def __init__( def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs):
self,
customer: Customer,
counter: Counter,
allowed_prices: dict[int, Price],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_prices = allowed_prices self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -584,6 +606,15 @@ class BasketItemForm(forms.Form):
class BaseBasketForm(forms.BaseFormSet): class BaseBasketForm(forms.BaseFormSet):
# Minimum amount of money there must be on the account after the transaction
# If None, the min balance check is skipped
min_result_balance: ClassVar[int | None] = 0
def __init__(self, *args, customer: Customer, counter: Counter, **kwargs):
super().__init__(*args, **kwargs)
self.customer = customer
self.counter = counter
def clean(self): def clean(self):
self.forms = [form for form in self.forms if form.cleaned_data != {}] self.forms = [form for form in self.forms if form.cleaned_data != {}]
@@ -592,8 +623,8 @@ class BaseBasketForm(forms.BaseFormSet):
self._check_forms_have_errors() self._check_forms_have_errors()
self._check_product_are_unique() self._check_product_are_unique()
self._check_recorded_products(self[0].customer) self._check_recorded_products()
self._check_enough_money(self[0].counter, self[0].customer) self._check_account_balance()
def _check_forms_have_errors(self): def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self): if any(len(form.errors) > 0 for form in self):
@@ -604,12 +635,35 @@ class BaseBasketForm(forms.BaseFormSet):
if len(price_ids) != len(self.forms): if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries.")) raise forms.ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer): @cached_property
self.total_price = sum([data["total_price"] for data in self.cleaned_data]) def total_price(self):
if self.total_price > customer.amount: refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
raise forms.ValidationError(_("Not enough money")) total_other = sum(
form.cleaned_data["total_price"]
for form in self.forms
if form.price.product.product_type_id != refill
)
total_refill = sum(
form.cleaned_data["total_price"]
for form in self.forms
if form.price.product.product_type_id == refill
)
return total_other - total_refill
def _check_recorded_products(self, customer: Customer): def _check_account_balance(self):
result_balance = self.customer.amount - self.total_price
if (
self.min_result_balance is not None
and self.min_result_balance > result_balance
):
raise forms.ValidationError(_("Not enough money"))
if result_balance > settings.SITH_ACCOUNT_MAX_MONEY:
raise ValidationError(
_("There cannot be more than %(money)d€ on an AE account")
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
)
def _check_recorded_products(self):
"""Check for, among other things, ecocups and pitchers""" """Check for, among other things, ecocups and pitchers"""
items = defaultdict(int) items = defaultdict(int)
for form in self.forms: for form in self.forms:
@@ -618,7 +672,7 @@ class BaseBasketForm(forms.BaseFormSet):
returnables = list( returnables = list(
ReturnableProduct.objects.filter( ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids) Q(product_id__in=ids) | Q(returned_product_id__in=ids)
).annotate_balance_for(customer) ).annotate_balance_for(self.customer)
) )
limit_reached = [] limit_reached = []
for returnable in returnables: for returnable in returnables:
+4 -1
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.core.mail import send_mass_mail from django.core.mail import send_mass_mail
@@ -13,6 +13,9 @@ from django.utils.translation import gettext as _
from core.models import User, UserQuerySet from core.models import User, UserQuerySet
from counter.models import AccountDump, Counter, Customer, Product, Selling from counter.models import AccountDump, Counter, Customer, Product, Selling
if TYPE_CHECKING:
from collections.abc import Iterable
class Command(BaseCommand): class Command(BaseCommand):
"""Effectively dump the inactive users. """Effectively dump the inactive users.
+67
View File
@@ -0,0 +1,67 @@
from typing import TYPE_CHECKING, Callable
from django.utils.functional import SimpleLazyObject
from counter.models import Permanency
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
from django.http import HttpRequest, HttpResponse
from core.models import User
SESSION_PERMANENCES_KEY = "permanence_ids"
def get_cached_barmen(request: HttpRequest) -> set[User]:
if not hasattr(request, "_cached_barmen"):
session: SessionBase = request.session
if session_ids := session.get(SESSION_PERMANENCES_KEY, None):
# Get ongoing permanences which id is in session.
# Note : we store permanence ids rather than user id to be sure
# not to wrongfully mark someone as logged here,
# even if it logged out then logged in elsewhere.
permanences = (
Permanency.objects.filter(end=None, id__in=session_ids)
.order_by("id")
.select_related("user")
)
# if the list of permanences occurring on this device has changed
# since the last page load, change the ids stored in session
real_ids = [p.id for p in permanences]
if real_ids != session_ids:
session[SESSION_PERMANENCES_KEY] = real_ids
request._cached_barmen = {p.user for p in permanences}
else:
request._cached_barmen = set()
return request._cached_barmen
class BarmenMiddleware:
"""Inject barmen logged in the current session.
In a similar fashion as `request.user`, `request.barmen` contains
users that are barmen in the current session, and ONLY them ;
if a user is logged as a barman on another session,
it will not be in `request.barmen`.
Notes:
In case of ended permanence, users will be automatically
removed from `request.barmen`.
However, in case of newly started permanence, this middleware
cannot add new barmen in the session data, so that operation
must be explicitly done in the barman login view.
"""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest):
request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request))
return self.get_response(request)
@@ -1,11 +1,14 @@
# Generated by Django 4.2.17 on 2024-12-08 13:30 # Generated by Django 4.2.17 on 2024-12-08 13:30
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Count from django.db.models import Count
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def delete_duplicates(apps: StateApps, schema_editor): def delete_duplicates(apps: StateApps, schema_editor):
"""Delete cards of users with more than one student cards. """Delete cards of users with more than one student cards.
@@ -1,6 +1,10 @@
# Generated by Django 4.2.17 on 2024-12-15 17:53 # Generated by Django 4.2.17 on 2024-12-15 17:53
from typing import TYPE_CHECKING
from django.db import migrations, models from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps from django.db.migrations.state import StateApps
@@ -1,9 +1,13 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59 # Generated by Django 5.2.8 on 2025-11-19 17:59
from typing import TYPE_CHECKING
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_selling_payment_method(apps: StateApps, schema_editor): def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it # 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it
@@ -1,13 +1,16 @@
# Generated by Django 5.2.8 on 2026-02-10 15:40 # Generated by Django 5.2.8 on 2026-02-10 15:40
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
import django.utils.timezone import django.utils.timezone
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import OuterRef, Subquery from django.db.models import OuterRef, Subquery
from counter.models import Selling from counter.models import Selling
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def apply_product_history_dates(apps: StateApps, schema_editor): def apply_product_history_dates(apps: StateApps, schema_editor):
"""Approximate a posteriori the value of created_at and updated_at.""" """Approximate a posteriori the value of created_at and updated_at."""
+5 -1
View File
@@ -1,11 +1,15 @@
# Generated by Django 5.2.11 on 2026-02-18 13:30 # Generated by Django 5.2.11 on 2026-02-18 13:30
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
import counter.fields import counter.fields
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_prices(apps: StateApps, schema_editor): def migrate_prices(apps: StateApps, schema_editor):
Product = apps.get_model("counter", "Product") Product = apps.get_model("counter", "Product")
@@ -0,0 +1,25 @@
# Generated by Django 5.2.13 on 2026-05-13 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0039_price")]
operations = [
migrations.RemoveField(model_name="product", name="buying_groups"),
migrations.AddField(
model_name="product",
name="clic_limit",
field=models.PositiveSmallIntegerField(
blank=True,
help_text=(
"If a limit is set, the product won't be purchasable "
"anymore on the eboutic once the latter is reached."
),
null=True,
verbose_name="clic limit",
),
),
migrations.RemoveField(model_name="counter", name="token"),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.2.14 on 2026-06-02 10:45
import django_countries.fields
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("counter", "0040_product_clic_limit")]
operations = [
migrations.AlterField(
model_name="billinginfo",
name="country",
field=django_countries.fields.CountryField(
max_length=2, verbose_name="Country"
),
),
migrations.AlterField(
model_name="billinginfo",
name="phone_number",
field=phonenumber_field.modelfields.PhoneNumberField(
max_length=128, region=None, verbose_name="Phone number"
),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.2.15 on 2026-06-07 12:08
from django.db import migrations
import counter.fields
class Migration(migrations.Migration):
dependencies = [("counter", "0041_alter_billinginfo_country_and_more")]
operations = [
migrations.AlterField(
model_name="customer",
name="amount",
field=counter.fields.CurrencyField(
decimal_places=2,
default=0,
max_digits=12,
max_value=250,
verbose_name="amount",
),
),
migrations.AlterField(
model_name="refilling",
name="amount",
field=counter.fields.CurrencyField(
decimal_places=2, max_digits=12, min_value=0.01, verbose_name="amount"
),
),
]
+71 -33
View File
@@ -22,18 +22,19 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING, Literal, Self from typing import Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value from django.db.models import Exists, F, Max, OuterRef, Q, QuerySet, Subquery, Sum, Value
from django.db.models.functions import Coalesce, Concat, Length from django.db.models.functions import Coalesce, Concat, Length
from django.forms import ValidationError from django.forms import ValidationError
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
from django_countries.fields import CountryField from django_countries.fields import CountryField
@@ -47,9 +48,6 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter: def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first() return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -101,7 +99,9 @@ class Customer(models.Model):
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
account_id = models.CharField(_("account id"), max_length=10, unique=True) account_id = models.CharField(_("account id"), max_length=10, unique=True)
amount = CurrencyField(_("amount"), default=0) amount: CurrencyField = CurrencyField(
_("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0
)
objects = CustomerQuerySet.as_manager() objects = CustomerQuerySet.as_manager()
@@ -158,13 +158,15 @@ class Customer(models.Model):
unique_fields=["customer", "returnable"], unique_fields=["customer", "returnable"],
) )
@property @cached_property
def can_buy(self) -> bool: def can_buy(self) -> bool:
"""Check if whether this customer has the right to purchase any item.""" """Check if whether this customer has the right to purchase any item."""
subscription = self.user.subscriptions.order_by("subscription_end").last() subscription_end = self.user.subscriptions.aggregate(
if subscription is None: res=Max("subscription_end")
).get("res")
if subscription_end is None:
return False return False
return (date.today() - subscription.subscription_end) < timedelta(days=90) return (date.today() - subscription_end) < timedelta(days=90)
@classmethod @classmethod
def get_or_create(cls, user: User) -> tuple[Customer, bool]: def get_or_create(cls, user: User) -> tuple[Customer, bool]:
@@ -230,15 +232,8 @@ class BillingInfo(models.Model):
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True) address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
city = models.CharField(_("City"), max_length=50) city = models.CharField(_("City"), max_length=50)
country = CountryField(blank_label=_("Country")) country = CountryField(_("Country"))
phone_number = PhoneNumberField(_("Phone number"))
# This table was created during the A22 semester.
# However, later on, CA asked for the phone number to be added to the billing info.
# As the table was already created, this new field had to be nullable,
# even tough it is required by the bank and shouldn't be null.
# If one day there is no null phone number remaining,
# please make the field non-nullable.
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
def __str__(self): def __str__(self):
return f"{self.first_name} {self.last_name}" return f"{self.first_name} {self.last_name}"
@@ -353,6 +348,40 @@ class ProductType(OrderedModel):
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
class ProductQuerySet(models.QuerySet):
def under_clic_limit(self) -> Self:
"""Filter product which clic limit isn't reached yet.
The clic limit is reached when the amount of sales
and of items in a basket for less than 15 minutes
is greater or equal than `Product.clic_limit`.
"""
# import here to avoid circular import
from eboutic.models import BasketItem
nb_click_subquery = Subquery(
Selling.objects.filter(product_id=OuterRef("id"))
.values("product_id")
.annotate(res=Sum("quantity", default=0))
.values("res")[:1]
)
nb_basket_items_subquery = Subquery(
BasketItem.objects.filter(
product_id=OuterRef("id"),
basket__date__gt=now()
- settings.SITH_EBOUTIC_BASKET_TIMEOUT
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
)
.values("product_id")
.annotate(res=Sum("quantity"))
.values("res")[:1]
)
return self.annotate(
clicked=Coalesce(nb_click_subquery, 0),
reserved=Coalesce(nb_basket_items_subquery, 0),
).filter(Q(clic_limit=None) | Q(clic_limit__gt=(F("clicked") + F("reserved"))))
class Product(models.Model): class Product(models.Model):
"""A product, with all its related information.""" """A product, with all its related information."""
@@ -370,8 +399,7 @@ class Product(models.Model):
) )
code = models.CharField(_("code"), max_length=16, blank=True) code = models.CharField(_("code"), max_length=16, blank=True)
purchase_price = CurrencyField( purchase_price = CurrencyField(
_("purchase price"), _("purchase price"), help_text=_("Initial cost of purchasing the product")
help_text=_("Initial cost of purchasing the product"),
) )
icon = ResizedImageField( icon = ResizedImageField(
height=70, height=70,
@@ -388,13 +416,21 @@ class Product(models.Model):
tray = models.BooleanField( tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False _("tray price"), help_text=_("Buy five, get the sixth free"), default=False
) )
buying_groups = models.ManyToManyField( clic_limit = models.PositiveSmallIntegerField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True _("clic limit"),
help_text=_(
"If a limit is set, the product won't be purchasable "
"anymore on the eboutic once the latter is reached."
),
null=True,
blank=True,
) )
archived = models.BooleanField(_("archived"), default=False) archived = models.BooleanField(_("archived"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True) updated_at = models.DateTimeField(_("updated at"), auto_now=True)
objects = ProductQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _("product") verbose_name = _("product")
@@ -580,7 +616,6 @@ class Counter(models.Model):
view_groups = models.ManyToManyField( view_groups = models.ManyToManyField(
Group, related_name="viewable_counters", blank=True Group, related_name="viewable_counters", blank=True
) )
token = models.CharField(_("token"), max_length=30, null=True, blank=True)
objects = CounterQuerySet.as_manager() objects = CounterQuerySet.as_manager()
@@ -733,10 +768,8 @@ class Counter(models.Model):
# but they share the same primary key # but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list) return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_prices_for( def get_prices_for(self, customer: Customer) -> PriceQuerySet:
self, customer: Customer, *, order_by: Sequence[str] | None = None return (
) -> list[Price]:
qs = (
Price.objects.filter( Price.objects.filter(
product__counters=self, product__product_type__isnull=False product__counters=self, product__product_type__isnull=False
) )
@@ -744,9 +777,6 @@ class Counter(models.Model):
.select_related("product", "product__product_type") .select_related("product", "product__product_type")
.prefetch_related("groups") .prefetch_related("groups")
) )
if order_by:
qs = qs.order_by(*order_by)
return list(qs)
class CounterSellers(models.Model): class CounterSellers(models.Model):
@@ -797,7 +827,7 @@ class Refilling(models.Model):
counter = models.ForeignKey( counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
) )
amount = CurrencyField(_("amount")) amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01)
operator = models.ForeignKey( operator = models.ForeignKey(
User, User,
related_name="refillings_as_operator", related_name="refillings_as_operator",
@@ -851,6 +881,14 @@ class Refilling(models.Model):
return False return False
return user.is_owner(self.counter) and self.payment_method != "CARD" return user.is_owner(self.counter) and self.payment_method != "CARD"
def clean(self):
super().clean()
if (self.amount + self.customer.amount) > settings.SITH_ACCOUNT_MAX_MONEY:
raise ValidationError(
_("There cannot be more than %(money)d€ on an AE account")
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.customer.amount -= self.amount self.customer.amount -= self.amount
self.customer.save() self.customer.save()
@@ -1079,7 +1117,7 @@ class Permanency(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
start = models.DateTimeField(_("start date")) start = models.DateTimeField(_("start date"))
end = models.DateTimeField(_("end date"), null=True, db_index=True) end = models.DateTimeField(_("end date"), null=True, blank=True, db_index=True)
activity = models.DateTimeField(_("last activity date"), auto_now=True) activity = models.DateTimeField(_("last activity date"), auto_now=True)
class Meta: class Meta:
+7 -14
View File
@@ -20,41 +20,34 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import random
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from core.middleware import get_signal_request from core.middleware import get_signal_request
from core.models import OperationLog from core.models import OperationLog
from counter.models import Counter, Refilling, Selling from counter.models import Refilling, Selling
def write_log(instance, operation_type): def write_log(instance: Selling | Refilling, operation_type):
def get_user(): def get_user():
request = get_signal_request() request = get_signal_request()
if not request: if not request:
return None return None
# Get a random barmen if deletion is from a counter if request.barmen:
session = getattr(request, "session", {}) return random.choice(list(request.barmen))
session_token = session.get("counter_token", None)
if session_token:
counter = Counter.objects.filter(token=session_token).first()
if counter and len(counter.barmen_list) > 0:
return counter.get_random_barman()
# Get the current logged user if not from a counter # Get the current logged user if not from a counter
if request.user and not request.user.is_anonymous: if request.user.is_authenticated:
return request.user return request.user
# Return None by default
return None return None
OperationLog( OperationLog(
label=str(instance), label=str(instance), operator=get_user(), operation_type=operation_type
operator=get_user(),
operation_type=operation_type,
).save() ).save()
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts"; import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
import { import {
type CounterSchema, type CounterSchema,
counterSearchCounter, counterSearchCounter,
@@ -28,14 +28,23 @@ export class ProductAjaxSelect extends AjaxSelect {
return []; return [];
} }
// In the context in which this method is called, `this` might be shadowed
// We need to call it explicitly from the class itself
private static getName(
item: SimpleProductSchema,
sanitize: typeof escape_html,
): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
}
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span> <span class="select-item-text">${ProductAjaxSelect.getName(item, sanitize)}</span>
</div>`; </div>`;
} }
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`; return `<span>${ProductAjaxSelect.getName(item, sanitize)}</span>`;
} }
} }
@@ -44,7 +53,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id"; protected valueField = "id";
protected labelField = "name"; protected labelField = "name";
protected searchField = ["name"]; protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[]; private productTypes = null as ProductTypeSchema[] | null;
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types // The production database has a grand total of 26 product types
@@ -52,7 +61,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
// Thus, it's appropriate to fetch all product types during first use, // Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again. // then to reuse the result again and again.
if (this.productTypes === null) { if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null; this.productTypes = (await producttypeFetchAll()).data || [];
} }
return this.productTypes.filter((t) => return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()), t.name.toLowerCase().includes(query.toLowerCase()),
@@ -1,12 +1,12 @@
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types"; import type { RecursivePartial, TomSettings } from "tom-select/src/types";
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts"; import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components.ts"; import { registerComponent } from "#core:utils/web-components";
const productParsingRegex = /^(\d+x)?(.*)/i; const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] { function parseProduct(query: string): [number, string] {
const parsed = productParsingRegex.exec(query); const parsed = productParsingRegex.exec(query) as RegExpExecArray;
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]]; return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
} }
@@ -59,17 +59,10 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return onOptionSelect.call( return onOptionSelect.call(
this.widget, this.widget,
evt, evt,
this.widget.getOption(value, true), this.widget.getOption(value, true) as HTMLElement,
); );
}, },
); );
this.widget.hook("after", "onOptionSelect", () => {
/* Focus the next element if it's an input */
if (this.nextElementSibling.nodeName === "INPUT") {
(this.nextElementSibling as HTMLInputElement).focus();
}
});
} }
protected tomSelectSettings(): RecursivePartial<TomSettings> { protected tomSelectSettings(): RecursivePartial<TomSettings> {
/* We disable the dropdown on focus because we're going to always autofocus the widget */ /* We disable the dropdown on focus because we're going to always autofocus the widget */
@@ -77,14 +70,15 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
...super.tomSelectSettings(), ...super.tomSelectSettings(),
openOnFocus: false, openOnFocus: false,
// We make searching on exact code matching a higher priority // We make searching on exact code matching a higher priority
// We need to manually set weights or it results on an inconsistent // We need to manually set weights, or it results on an inconsistent
// behavior between production and development environment // behavior between production and development environment
//
// SearchField can be an object array (according to the docs),
// but it is unproperly typed, so we must force-cast to trick TS
searchField: [ searchField: [
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 }, { field: "text", weight: 0.5 },
], ] as unknown as string[],
}; };
} }
} }

Some files were not shown because too many files have changed in this diff Show More