554 Commits
Author SHA1 Message Date
imperosol 87d247dbb4 wip 2026-10-02 15:35:29 +02:00
imperosol 7ab412a59b User timetable save 2026-10-02 15:35:29 +02:00
imperosol 12fa99eb50 add show_my_timetable to VisibilityForm 2026-10-02 15:35:29 +02:00
imperosol 5a9ed1342b add timetable visibility to UserVisibilityForm 2026-10-02 15:35:29 +02:00
imperosol 7a0df3d8a7 api to save timetable 2026-10-02 15:35:29 +02:00
imperosol 7044a06546 add models 2026-10-02 15:35:29 +02:00
thomas girod f211d0ff85 Merge pull request #1220 from ae-utbm/discord-auth
Third-party authentication
2026-10-02 14:55:47 +02:00
thomas girod 0bd557ae73 Merge pull request #1495 from ae-utbm/cotis-stat-quick-fix
Small improvements on subscription stats view
2026-10-02 13:49:01 +02:00
imperosol 3deefbe847 apply docs review comments 2026-10-02 09:48:23 +02:00
imperosol b2707b7862 fix: don't send callback request if data has been modified 2026-10-02 09:48:23 +02:00
imperosol 7a0f4217cc apply review comments 2026-10-02 09:48:23 +02:00
imperosol f067c60a8c tweak documentation 2026-10-02 09:48:23 +02:00
imperosol 5bea3f14e8 doc: third-party auth 2026-10-02 09:48:21 +02:00
imperosol 1efbf93f62 translation: third-party authentication 2026-10-02 09:48:09 +02:00
imperosol a84224c6ff write tests 2026-10-02 09:48:07 +02:00
imperosol 6ef0166540 third-party authentication views 2026-10-02 09:46:52 +02:00
imperosol e3a9e23d6a hmac_hexdigest util function 2026-10-01 10:17:06 +02:00
imperosol d6ab0e17f8 add hmac_key to ApiClient 2026-10-01 10:17:06 +02:00
imperosol 225b8cced9 move ResultConverter to core app 2026-10-01 10:17:06 +02:00
imperosol f271b9d1df feat: api route to get api client infos 2026-10-01 10:17:06 +02:00
thomas girod e39a73ae32 Merge pull request #1488 from ae-utbm/cgu-approve
Force CGU/ToS approval on account creation
2026-10-01 09:43:02 +02:00
thomas girod ad02026d63 Merge pull request #1506 from ae-utbm/news-next-date
display the date of next happening in NewsDetailView
2026-09-30 14:34:04 +02:00
imperosol a5440e3166 display the date of next happening in NewsDetailView 2026-09-28 19:32:40 +02:00
thomas girod 796ef72fa1 Merge pull request #1499 from ae-utbm/sas-replacestate
don't push state on sas image change
2026-09-28 17:33:46 +02:00
imperosol 44f9965557 show tooltips on subscription chart hover 2026-09-28 17:30:20 +02:00
imperosol 37871b386a optimize subscriptions stats 2026-09-28 17:30:20 +02:00
imperosol c8f4d12fc4 remove date selection from subscription stats view
Ca n'a jamais marché depuis 2017
2026-09-28 17:30:20 +02:00
imperosol 63764f54ba better subscription generation in populate_more 2026-09-28 17:30:20 +02:00
thomas girod 2d8f56f1f7 Merge pull request #1500 from ae-utbm/test-populate-more
test populate_more command
2026-09-28 16:22:02 +02:00
imperosol 809df909fd test populate_more command 2026-09-27 21:49:47 +02:00
imperosol ef1ff862a8 don't push state on sas image change 2026-09-27 19:26:02 +02:00
imperosol 19f0f23a05 use DatetimeField to store cgu approval
Au lieu de dire si l'utilisateur a approuvé les CGUs ou non, on dit quand est-ce qu'il les a approuvées. Si la date de la dernière approbation est antérieure à la date de la dernière révision des CGUs, alors on demande à nouveau de les approuver.
2026-09-25 15:43:36 +02:00
imperosol 6f701a9579 default cgu_approved=True for populated users 2026-09-25 13:21:19 +02:00
imperosol b607c86ab7 make CGU a Page instead of a File 2026-09-25 12:11:04 +02:00
imperosol 0c6a96a7ab add tests 2026-09-24 23:10:40 +02:00
imperosol c8813f708a make existing users approve CGU after login 2026-09-24 23:10:40 +02:00
imperosol 03514e4d97 refactor login.jinja 2026-09-24 23:10:40 +02:00
imperosol 4e3ed3212e force CGU/EULA approval on account creation 2026-09-24 23:10:40 +02:00
imperosol 5b02bc2e3b add CGU/EULA to populate command 2026-09-24 23:10:40 +02:00
thomas girod e937189b80 Merge pull request #1493 from ae-utbm/padding-refilling
add a space between inputs and submit in refill form
2026-09-21 10:34:03 +02:00
thomas girod 9b8ca76e33 Merge pull request #1494 from ae-utbm/fix-htmx-events
fix: `htmx:after:swap` was still using old syntax
2026-09-21 10:33:40 +02:00
imperosol aedfbf948e fix: htmx:after:swap was still using old syntax 2026-09-20 23:07:09 +02:00
imperosol 8175fc415c add a space between inputs and submit in refill form 2026-09-20 22:37:31 +02:00
thomas girod 994130550a Merge pull request #1492 from ae-utbm/fix-pagination
fix: broken matmat pagination
2026-09-20 22:28:48 +02:00
imperosol 44071f91f6 fix: broken matmat pagination 2026-09-20 22:26:27 +02:00
thomas girod bd02425b54 Merge pull request #1489 from ae-utbm/refill-perm
Refill perm
2026-09-18 18:30:06 +02:00
thomas girod 794d3841a0 Merge pull request #1490 from ae-utbm/fix-migrations
fix migrations
2026-09-18 16:43:05 +02:00
imperosol 62b49240d1 fix migrations 2026-09-18 11:09:56 +02:00
imperosol 46318be572 add tests 2026-09-17 12:51:16 +02:00
imperosol ee6b4d4b1e better selection of who can make people refill 2026-09-16 23:32:57 +02:00
imperosol 22bf38beaf implement SithModelBackend.with_perm 2026-09-16 23:32:57 +02:00
klmp200 38edfe9004 Merge pull request #1462 from ae-utbm/pedagogy
Use HTMX in UE details
2026-09-13 23:01:48 +02:00
thomas girod 0837a3e478 Merge pull request #1469 from ae-utbm/pedagogy-style
Add a switch to filter open UEs
2026-09-13 22:53:33 +02:00
klmp200 464f38af23 Fix review comments 2026-09-13 21:54:04 +02:00
imperosol e3b4d9e81f add tests 2026-09-13 12:18:39 +02:00
imperosol 49f32169fa add a switch to filter closed UEs 2026-09-13 10:10:15 +02:00
klmp200 02df76c31f Add comment view permission tests 2026-09-12 23:37:11 +02:00
klmp200 f3ba4fb31c Fix UE tests 2026-09-12 23:02:43 +02:00
klmp200 e80a43193f Fix ue comment view permission
Alternative queryset
2026-09-12 21:41:46 +02:00
klmp200 aacfba679d Use HTMX in UE details 2026-09-12 21:38:20 +02:00
thomas girod dd8803e617 Merge pull request #1476 from ae-utbm/ts-guide
ts-ify guide-index.ts
2026-09-12 19:57:40 +02:00
imperosol ceb15b132b ts-ify guide-index.ts 2026-09-11 15:11:38 +02:00
thomas girod dae144e5b0 Merge pull request #1480 from ae-utbm/fix-file-delete
fix: user profile pict deletion cancel
2026-09-10 14:11:35 +02:00
imperosol d4187b7435 inline deletion submit buttons on fragment 2026-09-10 13:36:45 +02:00
imperosol db44e4bd02 fix user picture delete confirmation page 2026-09-09 22:48:34 +02:00
klmp200 8de4d9a4d0 Merge pull request #1481 from ae-utbm/htmx4
Fix bad hx-swap on club list and moderation view
2026-09-09 22:10:25 +02:00
thomas girod e960625933 Merge pull request #1478 from ae-utbm/timetable-og
add og tags to timetable generator
2026-09-09 19:31:27 +02:00
klmp200 5f3e2d13ba Merge pull request #1482 from ae-utbm/htmx4-aria-busy-plugin
Fix aria-busy during swap and make it a plugin
2026-09-09 08:18:05 +02:00
imperosol 80f113eaf6 add og tags to timetable generator 2026-09-09 07:22:13 +02:00
thomas girod ff72939503 Merge pull request #1484 from ae-utbm/edt-msg
show the broken line on timetable parsing error
2026-09-09 07:19:13 +02:00
imperosol ecaef644b3 show the broken line on timetable parsing error
Certains utilisateurs font remonter régulièrement que le générateur ne marche pas. C'est normal, on n'a pas forcément prévu tous les cas et on n'est pas à l'abri des changements de format de l'UTBM.

Ce qui est très embêtant, dans ces cas-là, c'est qu'on donne très peu d'informations sur ce qui n'a pas marché, et que les utilisateurs peuvent difficilement faire remonter plus que ça.

En décrivant la ligne qui n'a pas pu être parsée, ça devrait déjà rendre la gestion de ce genre de problème un peu plus facile, tout en étant plus transparent pour l'utilisateur
2026-09-08 23:49:04 +02:00
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
thomas girod 564baf3b56 Merge pull request #1471 from ae-utbm/linked-role-groups
Linked role groups
2026-09-08 12:11:37 +02:00
imperosol d4432a4963 add translations 2026-09-08 11:52:18 +02:00
imperosol 43eedb274a Show linked groups on club role form page 2026-09-08 11:52:18 +02:00
imperosol c4cbcc3560 Allow club role-group m2m links 2026-09-08 11:52:18 +02:00
klmp200 11206fae90 Merge pull request #1483 from ae-utbm/xapian
Upgrade xapian to 2.1.0
2026-09-08 11:47:14 +02:00
klmp200 bacf100f6d Upgrade xapian to 2.1.0 2026-09-08 09:17:04 +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
klmp200 cbd80c17a2 Fix aria-busy during swap and make it a plugin 2026-09-07 17:23:23 +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
thomas girod 99d85e0361 Merge pull request #1385 from ae-utbm/localstorage
Automatic localstorage cleaning
2026-05-22 08:21:36 +02:00
thomas girod cff0dd2cf6 Merge pull request #1405 from ae-utbm/fixes
Fixes
2026-05-21 22:54:00 +02:00
imperosol 5238e2e2d6 doc-comment explaining base-bundle-index.ts 2026-05-21 22:52:11 +02:00
imperosol 4375fdf150 fix: birthdays font-size too big 2026-05-21 22:51:33 +02:00
imperosol a10773bb37 fix: ClubRole migration 2026-05-21 22:50:56 +02:00
imperosol ce0ddcd184 replace exportToHtml by Object.assign(window, { obj }) 2026-05-21 22:41:51 +02:00
imperosol 6cec2e74d0 feat: versionedLocalStorage 2026-05-21 22:28:31 +02:00
imperosol 2228a3f961 use sessionStorage to cache user pictures
Le sessionStorage est automatiquement vidé à la fermeture de la page, ce qui, dans le cas des photos, est un peu plus fiable et correspond un peu mieux à nos besoins.
2026-05-21 22:28:23 +02:00
imperosol 4b369b73a7 feat: automatic localstorage cleaning 2026-05-21 22:28:20 +02:00
imperosol 73f422db23 refactor: move nested-key.d.ts 2026-05-21 22:24:16 +02:00
imperosol 0aed36c8d9 refactor: assemble main js files into a single bundle 2026-05-21 22:24:16 +02:00
imperosol e2c17175f5 feat: use a queue in user pictures localstorage 2026-05-21 18:45:24 +02:00
klmp200 59b4c4e73c Merge pull request #1390 from ae-utbm/dependencies
Upgrade python dependencies
2026-05-20 23:03:49 +02:00
thomas girod b8623eed11 Merge pull request #1391 from ae-utbm/notifications-magic
improve `$notifications`
2026-05-20 23:03:30 +02:00
thomas girod ed791dc544 Merge pull request #1388 from ae-utbm/club-link
Club links
2026-05-20 23:03:08 +02:00
klmp200 18e8088cf3 Upgrade python dependencies 2026-05-20 21:48:51 +02:00
thomas girod 5ef7fd0294 Merge pull request #1403 from ae-utbm/update-deps
Update deps
2026-05-20 19:33:37 +02:00
imperosol afa3ea4f2c update xapian 2026-05-20 14:32:14 +02:00
imperosol c38fe7f9ae update JS deps 2026-05-20 14:21:34 +02:00
imperosol c6c8781511 update python deps 2026-05-20 14:15:37 +02:00
thomas girod 30bd4fd3ea Merge pull request #1402 from ae-utbm/fix-trans
fix: translation of UT network subscription
2026-05-20 12:41:55 +02:00
imperosol 019ab95773 fix: translation of UT network subscription 2026-05-20 12:35:32 +02:00
imperosol c83ab7c2c1 apply review comments 2026-05-20 12:26:15 +02:00
imperosol 23dc7702c4 add club links to club list page 2026-05-20 12:26:15 +02:00
imperosol def4f80ec0 add translations 2026-05-20 12:26:15 +02:00
imperosol a5a31b6a4d display club links on club main page 2026-05-20 12:25:26 +02:00
imperosol eaf90efd5c feat: club link management in club edit view 2026-05-20 12:25:26 +02:00
imperosol 4d1019c955 generate club links in populate 2026-05-20 12:25:26 +02:00
imperosol 52f112a041 feat: add links to response of GET /api/club/{club_id} 2026-05-20 12:25:26 +02:00
imperosol e89be22c34 feat: ClubLink model 2026-05-20 12:25:26 +02:00
imperosol fc37cbb454 improve main page style 2026-05-20 11:01:32 +02:00
thomas girod e667058fe6 Merge pull request #1354 from ae-utbm/club-role-ui
Club role UI
2026-05-20 10:49:48 +02:00
imperosol ffa4f82c7d UI/UX tweaks 2026-05-20 10:41:19 +02:00
imperosol 307f1fb1fe add tests 2026-05-20 10:41:19 +02:00
imperosol b6522971b2 add translations 2026-05-20 10:41:19 +02:00
imperosol 66f826625e ask for user confirmation if its role was moved out of presidency 2026-05-20 10:41:19 +02:00
imperosol a74f0dde39 add links to club edit page 2026-05-20 10:41:19 +02:00
imperosol c7160727ce add help texts in the club roles edition page 2026-05-20 10:41:19 +02:00
imperosol 31f36506a4 feat: page to create club roles 2026-05-20 10:41:19 +02:00
imperosol 3d8a832aca feat: page to edit and reorder club role 2026-05-20 10:41:19 +02:00
imperosol 07b08180d5 adapt tests to new club roles framework 2026-05-20 10:41:19 +02:00
imperosol bfd147fd3c add forgotten check 2026-05-20 10:41:19 +02:00
imperosol b784fd7ac8 add translations 2026-05-20 10:41:19 +02:00
imperosol 204e4e97bd apply review comments 2026-05-20 10:39:52 +02:00
imperosol 57a422de8c add doc comments for notifications 2026-05-20 10:25:14 +02:00
imperosol 75f3094dd5 fix: wrong notification update with fragments 2026-05-19 14:43:43 +02:00
imperosol 68aa4515f9 improve $notifications 2026-05-16 20:04:54 +02:00
thomas girod 899fc90821 Merge pull request #1389 from ae-utbm/djdt
update django-debug-toolbar panels
2026-05-14 11:39:32 +02:00
thomas girod 40a67f8c08 Merge pull request #1387 from ae-utbm/club-list-tweak
Small club list tweaks
2026-05-14 11:39:15 +02:00
imperosol 51c8e1e3dc update django-debug-toolbar panels 2026-05-13 11:09:25 +02:00
imperosol d3094a850f fix: incorrect initial value for ClubSearchForm.club_status 2026-05-13 10:14:30 +02:00
imperosol 9fdd5565a1 add og tags to club list page 2026-05-13 10:14:30 +02:00
thomas girod f0ac35370e Merge pull request #1323 from ae-utbm/club-role
Dynamic club roles
2026-05-12 11:04:09 +02:00
imperosol 54d261142d create default club roles on club creation 2026-05-12 09:49:40 +02:00
imperosol 8876c64f54 add forgotten check 2026-05-12 09:49:40 +02:00
imperosol a40c43204a exclude inactive roles from attributable roles 2026-05-12 09:45:02 +02:00
imperosol 44e8ab4fb9 put roles at the right place when they are created 2026-05-12 09:45:02 +02:00
imperosol c9caa3f324 add translations 2026-05-12 09:45:00 +02:00
imperosol aaaaeb204f remove settings.SITH_MAXIMUM_FREE_ROLE 2026-05-12 09:44:28 +02:00
imperosol fa190f6909 adapt club members pages to new club roles framework 2026-05-12 09:44:28 +02:00
imperosol 898ce48cc8 adapt tests to new club roles framework 2026-05-12 09:44:28 +02:00
imperosol 74d8a374b9 change on_delete constraint for club pages 2026-05-12 09:44:28 +02:00
imperosol 11d9e912e1 adapt populate and populate_more 2026-05-12 09:44:28 +02:00
imperosol 5cf54729ae add ClubRole model 2026-05-12 09:44:28 +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
Titouan 387e5fe0a3 Merge pull request #1372 from ae-utbm/update_subscription
delete unused subscriptions
2026-05-10 22:22:04 +02:00
TitouanDor c371a5cf7a modif django.po 2026-05-10 20:27:45 +02:00
TitouanDor 07eae232d0 delete unused sub 2026-05-10 20:27:24 +02:00
klmp200 e4fa9c3c4a Merge pull request #1378 from ae-utbm/fix-subscriptions
Fix subscription form
2026-05-10 14:13:37 +02:00
klmp200 5551fdc953 Apply review comments 2026-05-10 13:39:20 +02:00
klmp200 2d86ba67f4 Fix subscription form 2026-05-10 13:37:40 +02:00
thomas girod deb83ec08f Merge pull request #1380 from ae-utbm/fragment-subscription
refactor: use `FragmentMixin` for subscription fragments
2026-05-10 13:17:41 +02:00
imperosol 94cda48508 fix: shortcut SubscriptionNewUserForm.clean if there are errors 2026-05-10 10:27:12 +02:00
imperosol 7bd2f1da96 refactor: use FragmentMixin for subscription fragments 2026-05-08 13:47:34 +02:00
klmp200 e2d2eb7470 Merge pull request #1377 from ae-utbm/fix-component-nesting
Fix component nesting bug
2026-05-08 13:01:09 +02:00
thomas girod a18f316088 Merge pull request #1362 from ae-utbm/improve-style
Small style improvement for main page
2026-05-08 11:07:52 +02:00
thomas girod fee1bbc5a5 Merge pull request #1366 from ae-utbm/update-ci
update CI
2026-05-08 09:06:48 +02:00
klmp200 cb1a330983 Fix component nesting bug 2026-05-07 13:37:37 +02:00
klmp200 1cfeeefa56 Merge pull request #1376 from ae-utbm/fix-link-once
Fix crashes on *-once elements when called at bad timings
2026-05-06 23:23:02 +02:00
thomas girod 0308d1c887 Merge pull request #1374 from ae-utbm/fix-duplicated-price
fix: duplicated prices on counters
2026-05-06 23:22:35 +02:00
thomas girod 9abd16c8f6 Merge pull request #1375 from ae-utbm/fix-eboutic-js
fix: `this.$refs.basketManagementForm.getElementById is not a function`
2026-05-06 23:22:06 +02:00
klmp200 0405ef424d Fix crashes on *-once elements when called at bad timings 2026-05-06 23:19:35 +02:00
imperosol b79b7cbcf5 fix: this.$refs.basketManagementForm.getElementById is not a function 2026-05-06 23:06:06 +02:00
imperosol 2c259de22c fix: duplicated prices on counters 2026-05-06 23:05:14 +02:00
thomas girod 38f2b6aa7b Merge pull request #1367 from ae-utbm/rolldown-inject
refactor: use rolldown builtin `inject`
2026-05-06 18:21:34 +02:00
thomas girod 37345f1bc4 Merge pull request #1360 from ae-utbm/image_rotation
properly rotate SAS pictures
2026-05-06 18:21:23 +02:00
thomas girod 151d17aca1 Merge pull request #1373 from ae-utbm/localsorage-pictures
fix: `QuotaExceededError` on user pictures load
2026-05-06 13:51:00 +02:00
imperosol d123e5e35b fix: QuotaExceededError on user pictures load 2026-05-06 13:37:55 +02:00
imperosol 00f7afb937 add translations 2026-05-02 17:59:06 +02:00
imperosol 2dbf4cff05 add og tags to eboutic page 2026-05-02 17:30:11 +02:00
imperosol f88c061b02 scss-ify eboutic.css 2026-05-02 17:29:04 +02:00
imperosol 4bd248f827 add transition to user whitelist input 2026-05-02 17:28:47 +02:00
imperosol 2aa6eed2fc improve main page style 2026-05-02 17:28:46 +02:00
imperosol 7fec05820c test: Picture.generate_thumbnails 2026-05-02 17:25:14 +02:00
imperosol 381f1ac829 refactor: use rolldown builtin inject 2026-05-02 17:23:35 +02:00
imperosol 22e6c09c36 remove dead code 2026-05-01 23:20:25 +02:00
imperosol 399a3813f0 feat: rotate pictures with API+AlpineJS 2026-05-01 23:20:15 +02:00
imperosol 441a016025 refactor Picture.generate_thumbnails 2026-05-01 23:15:11 +02:00
imperosol 060dde78e7 add update date to SithFile model 2026-05-01 19:18:38 +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
klmp200 f19b3056ef Fix notifications on messages containing quotes 2026-05-01 18:59:58 +02:00
thomas girod 200f71a762 Merge pull request #1359 from ae-utbm/album-thumbnail
Automatically resize album thumbnail
2026-05-01 18:58:27 +02:00
imperosol facfa1be89 test thumbnail management of AlbumEditForm 2026-05-01 18:56:12 +02:00
imperosol 4b9a953a20 Automatically resize album thumbnail 2026-05-01 18:56:12 +02:00
imperosol 5c17337595 update CI 2026-04-30 19:02:19 +02:00
thomas girod da621f6d7e Merge pull request #1364 from ae-utbm/update-dep
Update dep
2026-04-30 10:23:53 +02:00
imperosol 6b2b4d347f resolve warnings 2026-04-30 10:15:13 +02:00
imperosol 26483825a1 update JS dependencies 2026-04-29 19:14:51 +02:00
imperosol 9ef592849c update python dependencies 2026-04-29 19:10:02 +02:00
thomas girod c329fb1d0e Merge pull request #1309 from ae-utbm/price
Product prices
2026-04-29 18:49:57 +02:00
imperosol ef611ce1a7 unify eboutic and regular counter price selection 2026-04-29 12:59:01 +02:00
imperosol fb23d24db7 directly work on group ids
add tests
2026-04-29 12:53:20 +02:00
imperosol 16ca6c3ead fix existing tests 2026-04-29 12:53:20 +02:00
imperosol f8f85c2778 add translations 2026-04-29 12:53:20 +02:00
imperosol cf40e941d4 adapt formulas to new price system 2026-04-29 12:53:20 +02:00
imperosol adeadeaf70 adapt products export to new price system 2026-04-29 12:53:20 +02:00
imperosol 40fce1609d Price formset in the Product edit page 2026-04-29 12:53:20 +02:00
imperosol 0f02d55318 use new price system in populate 2026-04-29 12:53:20 +02:00
imperosol 965309d76c remove Product.selling_price and Product.special_selling_price 2026-04-29 12:53:20 +02:00
imperosol 26c6ab4a9f use new price system in counters 2026-04-29 12:53:20 +02:00
imperosol 2613ede59d use new price system in the eboutic 2026-04-29 12:53:20 +02:00
imperosol 6634bd987d refactor InvoiceItem and BasketItem models 2026-04-29 12:53:20 +02:00
imperosol 1c6d7f435a Price model 2026-04-29 12:53:20 +02:00
thomas girod f9c5297473 Merge pull request #1358 from ae-utbm/sas-style
Sas style improvement
2026-04-22 22:37:24 +02:00
imperosol 52117b5a24 add og tags to sas main page
Quand quelqu'un qui n'a pas le droit tente d'accéder au SAS, il reçoit un HTTP 200 au lieu d'un 403. C'est pas forcément le plus pertinent, mais autant en profiter pour mettre les tags og.
2026-04-22 15:02:03 +02:00
imperosol ae72a2e00f improve SAS picture tools style 2026-04-22 15:02:03 +02:00
thomas girod fdf89ea716 Merge pull request #1356 from ae-utbm/sas-parent-fix
actually fix bug where you can't select /SAS as a parent album
2026-04-22 13:06:03 +02:00
imperosol 3954f2f170 apply review comments 2026-04-22 10:59:56 +02:00
imperosol d36d672d0b actually fix bug where you can't select /SAS as a parent album 2026-04-22 00:07:39 +02:00
klmp200 da3602329c Merge pull request #1355 from ae-utbm/profile_whitelist
Fix hidden user can't search itself
2026-04-20 21:43:52 +02:00
klmp200 8b18999514 Fix hidden user can't search itself 2026-04-20 20:17:39 +02:00
klmp200 1d525ca6d4 Merge pull request #1337 from ae-utbm/album_fix
Fix bug where you can't select /SAS as a parent album
2026-04-16 15:37:33 +02:00
klmp200 4dea60ac66 Fix bug where you can't select /SAS as a parent album 2026-04-16 09:29:51 +02:00
klmp200 30df859039 Merge pull request #1339 from ae-utbm/docs-install
Add installation instructions for rhel
2026-04-05 16:40:10 +02:00
klmp200 9c3f846f98 Add installation instructions for rhel 2026-04-05 14:19:34 +02:00
klmp200 64ebe30f5e Merge pull request #1331 from ae-utbm/upgrade_xapian
Upgrade xapian to 1.4.31
2026-04-02 16:19:46 +02:00
klmp200 744ea20c33 Apply review comments 2026-04-02 16:16:27 +02:00
klmp200 3e0a1c7334 Add documentation for xapian 2026-04-02 11:21:46 +02:00
klmp200 ef036c135c Upgrade xapian to 1.4.31 and add sha256 check to avoid supply chain attack 2026-04-02 10:51:19 +02:00
klmp200 366d6c7f03 Merge pull request #1326 from ae-utbm/fix-link-script-once
Fix link-once and script-once not triggering when another one disappears
2026-04-01 22:45:00 +02:00
klmp200 2151bbf4c7 Merge pull request #1335 from ae-utbm/htmx_pagination
Allow custom target for htmx pagination
2026-04-01 22:26:12 +02:00
klmp200 64b3acff07 Allow custom target for htmx pagination 2026-04-01 19:56:54 +02:00
klmp200 6ef8b6b159 Create InheritedHtmlElements interface and create an ElementOnce interface 2026-04-01 19:48:02 +02:00
klmp200 39dee782cc InheritedHtmlElements outside of factory 2026-04-01 19:01:37 +02:00
thomas girod bda65f39af Merge pull request #1321 from ae-utbm/club-list
Rework club list page
2026-04-01 18:25:14 +02:00
imperosol dfe884484d apply correct default club_status when GET data is missing 2026-04-01 14:04:10 +02:00
klmp200 a0cce91bd5 Simplify ElementOnce observer 2026-03-31 18:04:22 +02:00
imperosol 0f00c91b59 push url on club search form submit 2026-03-30 19:39:13 +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
imperosol b5d8db0187 apply review comments 2026-03-29 10:41:00 +02:00
imperosol 26178f10c5 use HTMX for club list page search 2026-03-29 10:37:26 +02:00
imperosol 842ae5615d add tests 2026-03-29 10:31:53 +02:00
imperosol 5cd748e313 add translations 2026-03-29 10:31:53 +02:00
imperosol 2944804074 rework club list page 2026-03-29 10:31:53 +02:00
klmp200 fcce34fde5 Merge pull request #1329 from ae-utbm/fix_pagination
Fix pagination
2026-03-29 09:59:50 +02:00
klmp200 a213bc058d Add request argument to paginate_jinja and paginate_htmx 2026-03-28 23:13:42 +01:00
klmp200 71bfdf68b7 Use urlencode for querystring macro 2026-03-28 23:01:52 +01:00
klmp200 2a9bb46e97 Fix 500 error on all broken pagination pages where with context was missing 2026-03-28 14:01:47 +01:00
klmp200 efdf71d69e Over engineer ElementOnce web components 2026-03-26 23:48:40 +01:00
klmp200 3bc4f1300e Fix link-once and script-once not triggering when another one disappears 2026-03-26 23:48:38 +01:00
Titouan 182cdbe590 Merge pull request #1324 from ae-utbm/eurock
Eurock
2026-03-25 13:20:38 +01:00
TitouanDor ac33a5e6b2 run pre-commit 2026-03-24 14:32:30 +01:00
TitouanDor 068bb9ab83 add widget eurock 2026-03-24 14:19:10 +01:00
thomas girod f9910c3360 Merge pull request #1320 from ae-utbm/user-whitelist
feat: whitelist for user visibility
2026-03-23 23:21:30 +01:00
imperosol f0f8cc5604 add permission to AE board to see hidden users in populate 2026-03-23 23:03:53 +01:00
imperosol 2a8e810ad0 always show the show_my_stats input 2026-03-23 23:03:53 +01:00
imperosol 739a1bba47 Use whitelist for picture identifications 2026-03-23 23:03:53 +01:00
imperosol 180852a598 add explanation comment 2026-03-23 23:03:53 +01:00
imperosol c3989a0016 add translations 2026-03-23 23:03:53 +01:00
imperosol 435c8f9612 include visibility settings in the user preferences page 2026-03-23 23:03:53 +01:00
imperosol 3d7f57b8da feat: whitelist for user visibility 2026-03-23 23:03:53 +01:00
thomas girod ffa0b94408 Merge pull request #1319 from ae-utbm/show-my-stats
show user stats to subscribers if show_my_stats is enabled
2026-03-20 13:49:48 +01:00
thomas girod 22a1f4ba07 Merge pull request #1317 from ae-utbm/remove-settings
remove unused settings
2026-03-20 13:47:22 +01:00
TitouanDor 76396cdeb0 add partnership with eurock in eboutic 2026-03-16 16:07:25 +01:00
imperosol 1c0b89bfc7 show user stats to subscribers if show_my_stats is enabled 2026-03-14 16:23:56 +01:00
thomas girod d374ea9651 Merge pull request #1318 from ae-utbm/vite
upgrade to vite 8
2026-03-13 09:48:42 +01:00
imperosol 10a4e71b7a upgrade to vite 8
FASTER FASTER FASTER FASTER FASTER FASTER
2026-03-13 09:46:12 +01: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
imperosol f1a60e589a remove unused settings 2026-03-12 10:26:40 +01:00
thomas girod 00acda7ba3 Merge pull request #1316 from ae-utbm/update-deps
Update deps
2026-03-12 08:32:13 +01:00
imperosol 1686a9da87 update JS deps 2026-03-11 22:41:51 +01:00
imperosol 83255945c4 update python deps 2026-03-11 22:30:36 +01:00
thomas girod b4a6b6961b Merge pull request #1307 from ae-utbm/counter-sellers
Counter sellers
2026-03-11 18:09:49 +01:00
thomas girod 0f0702825e Merge pull request #1281 from ae-utbm/test_election
add test_election_form
2026-03-10 19:42:02 +01:00
imperosol b74b1ac691 refactor TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor 33d4a99a2c move form test into a class TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor c154b311c3 add test with wrong data form 2026-03-10 19:39:40 +01:00
TitouanDor fb8da93c68 add test_election_form 2026-03-10 19:39:40 +01:00
thomas girod 1845a7cbcf Merge pull request #1312 from ae-utbm/dynamic-formset
Dynamic formset
2026-03-10 19:31:49 +01:00
imperosol f17f17d8de use dynamic formset for product action formset 2026-03-10 19:26:30 +01:00
imperosol 7bb3d064ee add dynamic-formset-index.ts 2026-03-10 19:26:30 +01:00
imperosol 4f84ec09d7 add tests 2026-03-10 19:26:05 +01:00
imperosol 7e649b40c5 add translation 2026-03-10 19:26:05 +01:00
thomas girod 296feb6e32 Merge pull request #1305 from ae-utbm/user-all-groups
User all groups
2026-03-10 19:08:24 +01:00
imperosol 30663d87a4 directly work on group ids 2026-03-09 19:36:15 +01:00
thomas girod b5ff9b4c13 Merge pull request #1314 from ae-utbm/user-clubs
feat: API route to get user memberships
2026-03-09 19:06:30 +01:00
imperosol e2f6671ad0 apply review comments 2026-03-09 18:59:41 +01:00
imperosol 9a67926a49 feat: API route to get user memberships 2026-03-09 18:11:23 +01:00
imperosol 78c373f84e differentiate regular and temporary barmen on the counter edit view 2026-03-09 16:04:46 +01:00
imperosol a7c8b318bd add fields to CounterSellers 2026-03-09 16:04:46 +01:00
imperosol 1701ab5f33 feat: custom through model for Counter.sellers 2026-03-09 16:04:46 +01:00
imperosol 09a98db786 refactor election views permission check 2026-03-09 16:04:19 +01:00
imperosol 84ed180c1e refactor sas moderation view permission 2026-03-09 16:04:19 +01:00
imperosol 52759764a1 feat: User.all_groups 2026-03-09 16:04:19 +01:00
Titouan be1563f46f Merge pull request #1313 from ae-utbm/price_fix
modify price on discount
2026-03-08 15:37:26 +01:00
TitouanDor 5d3d44ec67 modify price on discount 2026-03-08 15:09:46 +01:00
thomas girod 25e19339ff Merge pull request #1310 from ae-utbm/product-input
exclude archived products from product autocompletion.
2026-03-07 16:40:41 +01:00
thomas girod 10c4a921db Merge pull request #1311 from ae-utbm/counter-products
Restrict products that non-admins can add to counter
2026-03-07 16:24:41 +01:00
imperosol 74bf462e90 restrict products that non-admins can add to counter 2026-03-07 16:08:14 +01:00
imperosol 8e4d367522 exclude archived products from product autocompletion.
Dans tous les contextes avec un champ Ajax sur les produits, on a besoin uniquement des produits non-archivés. C'est plus cohérent d'exclure les produits archivés de la recherche.
2026-03-06 18:40:21 +01:00
thomas girod f713903589 Merge pull request #1265 from ae-utbm/product-formula
Product formula
2026-03-04 17:55:26 +01:00
imperosol 9506c8688f show more infos on the formulas list page 2026-02-17 22:08:01 +01:00
imperosol f3f470ec6c make formula deletion page clearer 2026-02-17 22:08:01 +01:00
imperosol ced524587f add tests 2026-02-17 22:08:01 +01:00
imperosol 5f01f973de add translations 2026-02-17 22:08:01 +01:00
imperosol 6a6a7e949f add checks on ProductForm 2026-02-17 22:08:01 +01:00
imperosol 4e73f103d8 automatically apply formulas on click 2026-02-17 22:08:01 +01:00
imperosol b03346c733 product formulas management views 2026-02-17 22:08:01 +01:00
imperosol 7be1d1cc63 feat: ProductFormula model 2026-02-17 22:08:01 +01:00
thomas girod 71ed7cdf7d Merge pull request #1289 from ae-utbm/product_history
Product history
2026-02-17 22:05:59 +01:00
imperosol 43768171a1 show creation date on Product update page 2026-02-17 22:05:34 +01:00
imperosol 0eccb4a5b5 Add created_at and updated_at to Product model 2026-02-17 22:05:19 +01:00
thomas girod e7584c8c83 Merge pull request #1299 from ae-utbm/populate-more
add ban generation to populate_more
2026-02-17 22:04:18 +01:00
thomas girod ac06de4f55 Merge pull request #1300 from ae-utbm/csv-typo
fix: typo
2026-02-17 12:30:35 +01:00
imperosol e2fca3e6d2 fix: typo 2026-02-14 15:22:18 +01:00
imperosol 2138783bde add ban generation to populate_more 2026-02-14 15:14:45 +01:00
thomas girod 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
thomas girod 4391f63de8 Merge pull request #1292 from ae-utbm/merge-back
Merge back
2026-02-13 15:19:35 +01:00
klmp200 8b7eb6edf9 Merge branch 'master' into taiste 2026-02-13 15:16:26 +01:00
klmp200 f8cda3a31d Merge pull request #1291 from ae-utbm/update-hey-api
Update hey-api
2026-02-13 14:32:18 +01:00
klmp200 433d29fcdb Update hey-api 2026-02-13 14:27:52 +01:00
thomas girod 514b8bbec7 Merge pull request #1290 from ae-utbm/update-deps
Update deps
2026-02-13 14:21:15 +01:00
imperosol 84033f37cf update BiomeJS 2026-02-13 14:09:27 +01:00
imperosol e71f76ea91 remove shorten.min.js 2026-02-13 13:13:40 +01:00
imperosol 530475c4ee update JS dependencies 2026-02-13 11:58:20 +01:00
imperosol e992bebd68 update python dependencies 2026-02-13 11:52:02 +01:00
thomas girod 8f1c786aa2 Merge pull request #1274 from ae-utbm/remove-club-cache
remove cache calls to fetch user membership
2026-02-11 12:47:06 +01:00
imperosol c5ae81aae7 update docstrings 2026-02-10 13:08:36 +01:00
imperosol 252acc64c1 remove cache calls to fetch user membership 2026-02-10 13:08:36 +01:00
thomas girod 0d2430a5d4 Merge pull request #1288 from ae-utbm/cb-message
explanation message when eboutic bank payments are disabled
2026-02-09 19:37:12 +01:00
imperosol b6f77dea97 apply review suggestion 2026-02-09 15:24:52 +01:00
imperosol df2e65a991 explanation message when eboutic bank payments are disabled 2026-02-08 16:21:09 +01:00
thomas girod de776045a8 Merge pull request #1287 from ae-utbm/ruff
Update Ruff
2026-02-04 03:10:33 +01:00
imperosol 367ea703ce remove fmt: off 2026-02-03 21:23:34 +01:00
imperosol bdcb802da8 apply ruff rule PLW0108 2026-02-03 21:12:14 +01:00
imperosol 4e4b5a39f7 update ruff 2026-02-03 21:11:13 +01:00
klmp200 51534629ed Merge pull request #1279 from ae-utbm/fix_elections
fix: bad value for blank vote and better flow for invalid form
2026-02-03 15:18:33 +01:00
klmp200 c042c8e8a3 fix: bad value for blank vote and better flow for invalid form
* Add an error message when looking at a public election without being logged in
* Add correct value for blank vote on single vote field
* Redirect to view with an error message if an invalid form has been submitted
2026-02-03 10:05:36 +01:00
thomas girod 5af894060a Merge pull request #1273 from ae-utbm/fix-counter
fix: wrong quantity displayed on click after removing item
2026-01-21 22:42:27 +01:00
klmp200 679b8dac1c Merge pull request #1278 from ae-utbm/download-picture-fix
Fix image file generation on user image download
2026-01-21 22:04:09 +01:00
klmp200 e9eb3dc17d Fix image file generation on user image download
* Add image id on the name to avoid error with images with the exact same date (if we have epoch for example)
* Fix album name due to schema change not reflected here
2026-01-07 17:49:14 +01:00
klmp200 8c6f7c82c9 Merge pull request #1277 from ae-utbm/pedagogy
Don't craft urls in pedagogy frontend
2025-12-25 17:01:48 +01:00
klmp200 6ec1834540 Don't craft urls in pedagogy frontend 2025-12-24 14:48:03 +01:00
thomas girod 086a61f493 Merge pull request #1272 from ae-utbm/update-ue
rename UV to UE
2025-12-24 11:29:32 +01:00
imperosol 53a3dc0060 fix: wrong quantity displayed on click after removing item 2025-12-20 06:47:29 +01:00
Thomas Girod 775a3282dc rename UV to UE 2025-12-19 23:12:02 +01:00
thomas girod 32570ee03d Merge pull request #1266 from ae-utbm/matmat
Refactor Matmatronch
2025-12-18 13:13:28 +01:00
imperosol 2fa3597722 fix display of non field errors 2025-12-18 00:39:08 +01:00
klmp200 d484971dad Fix pagination on matmat, don't allow empty matmat search and add htmx pagination 2025-12-17 09:21:52 +01:00
klmp200 f24e39ccb7 Fix aria-busy when going backward on pages with htmx pagination 2025-12-17 00:01:48 +01:00
klmp200 3a57439d6e Merge pull request #1271 from ae-utbm/calendar-colors
Add different colors for recurring events on event calendar
2025-12-16 23:04:03 +01:00
thomas girod fbe5c741d1 Merge pull request #1270 from ae-utbm/calendar
Use ics rrule for recurrent event
2025-12-16 19:19:06 +01:00
klmp200 749cd067da Add different colors for recurring events on event calendar 2025-12-16 17:07:18 +01:00
Titouan 12b098feac Merge pull request #1269 from ae-utbm/dependence_update
update uv dependencies
2025-12-16 10:52:35 +01:00
imperosol 1abfbeb76c use ics rrule for recurrent event 2025-12-16 01:13:17 +01:00
TitouanDor 0fb86e5d77 modification pyproject.toml 2025-12-15 16:21:22 +01:00
TitouanDor 523e0ff0ee update uv dependencies 2025-12-15 15:22:14 +01:00
imperosol a68f16ba9d add tests 2025-11-30 19:12:37 +01:00
imperosol 1a99f4096e make matmatronch form more readable 2025-11-30 19:12:37 +01:00
imperosol 559a904e0d refactor: Matmatronch 2025-11-30 19:11:51 +01:00
imperosol fca6a58c5e feat: querystring jinja macro 2025-11-30 16:55:44 +01:00
imperosol 39c3e11d88 extract matmat forms into their own file 2025-11-29 14:48:30 +01:00
thomas girod d3edcaff14 Merge pull request #1264 from ae-utbm/refactor/user
Refactor some user views
2025-11-26 18:33:35 +01:00
imperosol 8c127a96f7 refactor: user godfathers views 2025-11-25 22:20:43 +01:00
imperosol 55d6e2bbec refactor: PasswordRootChangeView 2025-11-25 20:55:36 +01:00
imperosol e9fbac8264 test UserPreferencesView 2025-11-25 19:48:45 +01:00
imperosol 1911f2e6dd refactor: remove UserUpdateView.board_only
La variable n'a pas été utilisée depuis 2016
2025-11-25 19:47:52 +01:00
thomas girod 77bdc8dcb5 Merge pull request #1263 from ae-utbm/remove-group
refactor: remove useless Group methods
2025-11-25 16:42:50 +01:00
imperosol 00acdcd1a5 refactor: remove useless Group methods 2025-11-24 18:15:28 +01:00
thomas girod aa77cfd1c8 Merge pull request #1262 from ae-utbm/refactor/userstats
Refactor/userstats
2025-11-24 18:09:56 +01:00
imperosol 0d4b77ba1c take all purchases for global purchase sum 2025-11-24 17:00:28 +01:00
imperosol 5271783e88 refactor: user stats view 2025-11-24 16:49:22 +01:00
imperosol 4ff4d179a1 refactor: format_timedelta template filter 2025-11-24 16:49:15 +01:00
thomas girod 7cbb3a2c5d Merge pull request #1256 from ae-utbm/remove-is_validated
Database optimisations on counter
2025-11-24 16:46:15 +01:00
thomas girod a0768d6d7f Merge pull request #1261 from ae-utbm/refactor/index
refactor: `core/views/index.py`
2025-11-24 15:43:36 +01:00
imperosol f55627a292 refactor: core/views/index.py 2025-11-24 09:25:38 +01:00
thomas girod 4f802ac56e Merge pull request #1260 from ae-utbm/fix-warnings
Fix warnings
2025-11-24 07:43:51 +01:00
thomas girod 16a6e07d4b Merge pull request #1259 from ae-utbm/update-ninja
deps: bump django-ninja to 1.5.0
2025-11-24 07:43:39 +01:00
thomas girod 33d6300131 Merge pull request #1258 from ae-utbm/fix/product-action
fix: product scheduled action on product creation
2025-11-24 07:43:20 +01:00
imperosol 6709befb1f fix timezone issues 2025-11-23 01:30:44 +01:00
imperosol ddfb88ca2a remove settings.FORM_RENDERER 2025-11-23 01:22:15 +01:00
imperosol acdb9660f6 deps: bump django-ninja to 1.5.0 2025-11-23 00:48:32 +01:00
imperosol b60bd3a42b fix: product scheduled action on product creation
cf. issue #1257
2025-11-21 11:13:06 +01:00
imperosol 0c046b6164 translations 2025-11-19 21:03:55 +01:00
imperosol c588e5117d make Refilling.payment_method a SmallIntegerField 2025-11-19 21:03:55 +01:00
imperosol ad87617018 remove Refilling.bank 2025-11-19 21:03:55 +01:00
imperosol 56c2c2b70e remove Refilling.is_validated 2025-11-19 21:03:55 +01:00
imperosol 78fe4e52ca make Selling.payment_method a SmallIntegerField 2025-11-19 21:03:55 +01:00
imperosol 2a5893aa79 remove Selling.is_validated 2025-11-19 21:03:55 +01:00
thomas girod 0a4d21611e Merge pull request #1255 from ae-utbm/taiste
Refactors, better `PageRev` handling, better user invisibilisation and fixes
2025-11-19 14:02:59 +01:00
thomas girod 7373e3d9de Merge pull request #1254 from ae-utbm/refactor/page-merge
refactor detection of the need to merge `PageRev`
2025-11-19 13:52:52 +01:00
imperosol 3f4a41ba42 refactor detection of the need to merge PageRev 2025-11-19 13:51:38 +01:00
thomas girod 449abbb17e Merge pull request #1248 from ae-utbm/fix/api-barman-auth
fix: user search for anonymous sessions with logged barmen
2025-11-19 13:05:16 +01:00
thomas girod 9862e763ad Merge pull request #1249 from ae-utbm/membership-set-old
prevent csrf on `MembershipSetOldView`
2025-11-19 13:04:51 +01:00
imperosol 32e1f09d46 prevent csrf on MembershipSetOldView 2025-11-16 15:05:10 +01:00
imperosol f359fab6b4 style: class for <a>-like form submit buttons 2025-11-16 15:04:30 +01:00
imperosol 0b53db7a95 fix: user search for anonymous sessions with logged barmen
Quand une session n'était pas connectée en tant qu'utilisateur, mais avait des utilisateurs connectés en tant que barman, la route de recherche des utilisateurs était 401
2025-11-16 13:31:48 +01:00
imperosol d325b19383 typo in Sha512ApiKeyHasher docstring 2025-11-16 13:30:17 +01:00
imperosol 33cc9588b0 remove unused Mock 2025-11-16 13:12:58 +01:00
thomas girod 5f0d7c07ce Merge pull request #1246 from ae-utbm/club-sale
use FilterSchema for club sales view
2025-11-14 19:59:57 +01:00
imperosol 17421e5cc9 test ClubSellingCSVView 2025-11-12 22:00:18 +01:00
imperosol e00a64252e use FilterSchema for club sales. 2025-11-12 22:00:18 +01:00
thomas girod 926e5ae45c Merge pull request #1245 from ae-utbm/revert-gala-style
Revert "Custom style for Gala 2025"
2025-11-11 15:18:19 +01:00
imperosol a27d8d0755 Revert "Custom style for Gala 2025"
This reverts commit 8cbf42d714.
2025-11-11 15:16:45 +01:00
thomas girod 433fea1855 Merge pull request #1242 from ae-utbm/merge-rev
Reuse last PageRev if same author and small diff
2025-11-11 15:16:14 +01:00
imperosol c0ed5bd393 add diff ratio to the heuristic 2025-11-11 15:13:45 +01:00
imperosol ede15623df translations 2025-11-11 15:13:45 +01:00
imperosol b9aa07646a reuse last PageRev if same author and short time diff 2025-11-11 15:13:45 +01:00
imperosol 3c79bd4d01 test PageListView 2025-11-11 15:13:44 +01:00
imperosol 8819abe27c Custom 404 for Page 2025-11-11 15:13:44 +01:00
imperosol 30e76a5e39 move page templates to their own folder 2025-11-11 15:13:44 +01:00
imperosol d50bb0d9b1 remove dead code 2025-11-11 15:13:44 +01:00
thomas girod 6c5b348a0a Merge pull request #1244 from ae-utbm/subscription-defaults
add more default user infos on first subscription
2025-11-11 15:13:32 +01:00
imperosol d0340603a2 add more default user infos on first subscription 2025-11-11 15:07:06 +01:00
thomas girod 2d60ae2ed8 Merge pull request #1231 from ae-utbm/hide-user
better invisibilisation of hidden users
2025-11-11 14:34:57 +01:00
imperosol 80dbe7f742 exclude hidden users from ajax search 2025-11-11 14:31:20 +01:00
imperosol a571bda766 Show groups of Permission in admin 2025-11-11 14:31:20 +01:00
imperosol 04702335e2 rename User.is_subscriber_viewable => User.is_viewable 2025-11-11 14:31:20 +01:00
imperosol c942ff6aec don't show hidden users in picture identifications 2025-11-11 14:31:20 +01:00
thomas girod 164e8c7a53 Merge pull request #1243 from ae-utbm/remove-selectuser
remove unused `SelectUser`
2025-11-11 13:51:57 +01:00
imperosol 7042cc41f0 remove unused SelectUser 2025-11-11 13:49:33 +01:00
thomas girod 992b6d6b79 Merge pull request #1238 from ae-utbm/taiste
Sith theme, `Selling.date` index, galaxy simplification, OG tags, dependencies update, bugfixes and others
2025-11-10 13:19:43 +01:00
Kenneth Soares 710b4aa942 Merge pull request #1213 from ae-utbm/taiste
HTMX, Alpine, Invoice Calls, Products, Bugfixes, Other
2025-10-18 17:29:15 +02:00
Kenneth Soares 5fee2e4720 Merge pull request #1180 from ae-utbm/taiste
Com, Subscriptions, Posters, Others
2025-09-19 21:31:28 +02:00
428 changed files with 21098 additions and 11569 deletions

No files matched your search

+15 -21
View File
@@ -12,7 +12,7 @@ runs:
steps:
- name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: gettext
version: 1.0 # increment to reset cache
@@ -23,26 +23,29 @@ runs:
with:
redis-version: "7.x"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
version: "0.11.8"
enable-cache: false
cache-dependency-glob: "uv.lock"
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
uses: actions/cache@v5
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
uv-${{ runner.os }}
- name: Install dependencies
run: uv sync
run: uv sync --locked
shell: bash
- name: Install Xapian
@@ -50,15 +53,6 @@ runs:
run: uv run ./manage.py install_xapian
shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages
+4 -4
View File
@@ -18,8 +18,8 @@ jobs:
name: Launch pre-commits checks (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1
@@ -35,7 +35,7 @@ jobs:
pytest-mark: [not slow]
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- uses: ./.github/actions/setup_project
with:
full: true
@@ -49,7 +49,7 @@ jobs:
uv run coverage report
uv run coverage html
- name: Archive code coverage results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report
+1 -3
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0
uses: appleboy/ssh-action@v1.2.5
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -29,8 +29,6 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./.github/actions/setup_project
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v3
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
+1 -3
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0
uses: appleboy/ssh-action@v1.2.5
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -28,8 +28,6 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
+3
View File
@@ -24,6 +24,9 @@ node_modules/
# compiled documentation
site/
# rollup-bundle-visualizer report
.bundle-size-report.html
### Redis ###
# Ignore redis binary dump (dump.rdb) files
+3 -3
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.4
rev: v0.16.0
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,9 +12,9 @@ repos:
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"]
additional_dependencies: ["@biomejs/biome@2.5.1"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.10
rev: 3.0.11
hooks:
- id: djhtml
name: format templates
+1 -1
View File
@@ -1 +1 @@
3.12
3.14
+15 -2
View File
@@ -1,11 +1,15 @@
from typing import TYPE_CHECKING
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 api.hashers import generate_key
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
@admin.register(ApiClient)
class ApiClientAdmin(admin.ModelAdmin):
@@ -17,6 +21,15 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name",
)
autocomplete_fields = ("owner", "groups", "client_permissions")
readonly_fields = ("hmac_key",)
actions = ("reset_hmac_key",)
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
objs = list(queryset)
for obj in objs:
obj.reset_hmac(commit=False)
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
@admin.register(ApiKey)
+16
View File
@@ -0,0 +1,16 @@
from ninja_extra import ControllerBase, api_controller, route
from api.auth import ApiKeyAuth
from api.schemas import ApiClientSchema
@api_controller("/client")
class ApiClientController(ControllerBase):
@route.get(
"/me",
auth=[ApiKeyAuth()],
response=ApiClientSchema,
url_name="api-client-infos",
)
def get_client_info(self):
return self.context.request.auth
+5 -1
View File
@@ -1,9 +1,13 @@
from django.http import HttpRequest
from typing import TYPE_CHECKING
from ninja.security import APIKeyHeader
from api.hashers import get_hasher
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.http import HttpRequest
class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
+35
View File
@@ -0,0 +1,35 @@
from django import forms
from django.forms import HiddenInput
from django.utils.translation import gettext_lazy as _
class ThirdPartyAuthForm(forms.Form):
"""Form to complete to authenticate on the sith from a third-party app.
For the form to be valid, the user approve the EULA (french: CGU)
and give its username from the third-party app.
"""
cgu_accepted = forms.BooleanField(
required=True,
label=_("I have read and I accept the terms and conditions of use"),
error_messages={
"required": _("You must approve the terms and conditions of use.")
},
)
is_username_valid = forms.BooleanField(
required=True,
error_messages={"required": _("You must confirm that this is your username.")},
)
client_id = forms.IntegerField(widget=HiddenInput())
third_party_app = forms.CharField(widget=HiddenInput())
privacy_link = forms.URLField(widget=HiddenInput())
username = forms.CharField(widget=HiddenInput())
callback_url = forms.URLField(widget=HiddenInput())
signature = forms.CharField(widget=HiddenInput())
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
self.fields["is_username_valid"].label = _(
"I confirm that %(username)s is my username on %(app)s"
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
+1 -1
View File
@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
class Sha512ApiKeyHasher(BasePasswordHasher):
"""
An API key hasher using the sha256 algorithm.
An API key hasher using the sha512 algorithm.
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
It is insecure for use in hashing passwords, but is safe for hashing
+19
View File
@@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-10-26 10:15
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
dependencies = [("api", "0001_initial")]
operations = [
migrations.AddField(
model_name="apiclient",
name="hmac_key",
field=models.CharField(
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
),
),
]
+33 -22
View File
@@ -1,13 +1,20 @@
import secrets
from typing import Iterable
from django.contrib.auth.models import Permission
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey(
@@ -26,11 +33,10 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."),
related_name="clients",
)
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")
@@ -38,33 +44,38 @@ class ApiClient(models.Model):
def __str__(self):
return self.name
@cached_property
def all_permissions(self) -> set[str]:
permissions = (
Permission.objects.filter(
Q(group__group__in=self.groups.all()) | Q(clients=self)
)
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}
def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
return perm in self.all_permissions
if self._perm_cache is None:
group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified permissions."""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list)
def reset_hmac(self, *, commit: bool = True) -> str:
"""Reset and return the HMAC key for this client.
Args:
commit: if True (the default), persist the new hmac in db.
"""
self.hmac_key = get_hmac_key()
if commit:
self.save()
return self.hmac_key
class ApiKey(models.Model):
PREFIX_LENGTH = 5
+8 -11
View File
@@ -39,14 +39,16 @@ Example:
import operator
from functools import reduce
from typing import Any, Callable
from typing import TYPE_CHECKING, Any, Callable
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
from counter.models import Counter
from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
class IsInGroup(BasePermission):
@@ -186,12 +188,7 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
if "/counter/" not in request.META.get("HTTP_REFERER", ""):
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()
return is_logged_in_counter(request)
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
+23
View File
@@ -0,0 +1,23 @@
from ninja import ModelSchema, Schema
from pydantic import Field, HttpUrl
from api.models import ApiClient
from core.schemas import SimpleUserSchema
class ApiClientSchema(ModelSchema):
class Meta:
model = ApiClient
fields = ["id", "name"]
owner: SimpleUserSchema
permissions: list[str] = Field(alias="all_permissions")
class ThirdPartyAuthParamsSchema(Schema):
client_id: int
third_party_app: str
privacy_link: HttpUrl
username: str
callback_url: HttpUrl
signature: str
+32
View File
@@ -0,0 +1,32 @@
{% extends "core/base.jinja" %}
{% block content %}
<form method="post">
{% csrf_token %}
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
<p>
{% trans trimmed app=third_party_app %}
By ticking this box and clicking on the send button, you
acknowledge and agree to provide {{ app }} with your
first name, last name, nickname and any other information
that was the third party app was explicitly authorized to fetch
and that it must have acknowledged to you, in a complete and accurate manner.
{% endtrans %}
</p>
<p class="margin-bottom">
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
applies as soon as the form is submitted.
{% endtrans %}
</p>
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
<br>
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
<div class="row margin-bottom">
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
</div>
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
import pytest
from django.contrib.admin import AdminSite
from django.http import HttpRequest
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from api.admin import ApiClientAdmin
from api.models import ApiClient
@pytest.mark.django_db
def test_reset_hmac_action():
client_admin = ApiClientAdmin(ApiClient, AdminSite())
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
old_hmac_keys = [c.hmac_key for c in api_clients]
with assertNumQueries(2):
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
client_admin.reset_hmac_key(HttpRequest(), qs)
for c in api_clients:
c.refresh_from_db()
assert api_clients[0].hmac_key == old_hmac_keys[0]
assert api_clients[1].hmac_key == old_hmac_keys[1]
assert api_clients[2].hmac_key != old_hmac_keys[2]
assert api_clients[3].hmac_key != old_hmac_keys[3]
+22
View File
@@ -0,0 +1,22 @@
from typing import TYPE_CHECKING
import pytest
from django.urls import reverse
from model_bakery import baker
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
from api.schemas import ApiClientSchema
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_api_client_controller(client: Client):
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
assert res.status_code == 200
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
+59
View File
@@ -0,0 +1,59 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from api.models import ApiClient
from core.models import Group
class TestClientPermissions(TestCase):
@classmethod
def setUpTestData(cls):
cls.api_client = baker.make(ApiClient)
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
cls.api_client.groups.set(
[
baker.make(Group, permissions=cls.perms[0:3]),
baker.make(Group, permissions=cls.perms[3:5]),
]
)
cls.api_client.client_permissions.set(
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
)
def test_all_permissions(self):
assert self.api_client.all_permissions == {
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
}
def test_has_perm(self):
assert self.api_client.has_perm(
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
)
assert not self.api_client.has_perm(
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
)
def test_has_perms(self):
assert self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
]
)
assert not self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
],
)
@pytest.mark.django_db
def test_reset_hmac_key():
client = baker.make(ApiClient)
original_key = client.hmac_key
client.reset_hmac(commit=True)
assert len(client.hmac_key) == len(original_key)
assert client.hmac_key != original_key
+140
View File
@@ -0,0 +1,140 @@
from unittest import mock
from unittest.mock import Mock
from django.contrib.messages import Message, get_messages
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from api.models import ApiClient, get_hmac_key
from core.baker_recipes import subscriber_user
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
def mocked_post(*, ok: bool):
class MockedResponse(Mock):
@property
def ok(self):
return ok
def mocked():
return MockedResponse()
return mocked
class TestThirdPartyAuth(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
cls.api_client = baker.make(ApiClient)
def setUp(self):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
"privacy_link": "https://foobar.fr/",
"username": "bibou",
"callback_url": "https://callback.fr/",
}
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
self.callback_data = {
"user": UserProfileSchema.from_orm(self.user).model_dump()
}
self.callback_data["signature"] = hmac_hexdigest(
self.api_client.hmac_key, self.callback_data["user"]
)
self.url = reverse("api-link:third-party-auth", query=self.query)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
)
def test_callback_error(self):
"""Test that the user see the failure page if the callback request failed."""
self.client.force_login(self.user)
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
)
def test_wrong_signature(self):
"""Test that a 403 is raised if the signature of the query is wrong."""
self.client.force_login(subscriber_user.make())
new_key = get_hmac_key()
del self.query["signature"]
self.query["signature"] = hmac_hexdigest(new_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message=(
"La signature est incorrecte. "
"Nous ne pouvons pas garantir l'authenticité de la requête."
),
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
def test_cgu_not_accepted(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200 # no redirect means invalid form
res = self.client.post(
self.url,
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
)
assert res.status_code == 200
def test_invalid_client(self):
self.client.force_login(self.user)
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
def test_missing_parameter(self):
self.client.force_login(self.user)
del self.query["username"]
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
+15
View File
@@ -1,6 +1,10 @@
from django.urls import path, register_converter
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI(
title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques",
@@ -9,3 +13,14 @@ api = NinjaExtraAPI(
auth=[SessionAuth()],
)
api.auto_discover_controllers()
register_converter(ResultConverter, "res")
urlpatterns = [
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
path(
"auth/<res:result>/",
ThirdPartyAuthResultView.as_view(),
name="third-party-auth-result",
),
]
+146
View File
@@ -0,0 +1,146 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
import sentry_sdk
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
from ninja_extra.shortcuts import get_object_or_none
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
from api.schemas import ThirdPartyAuthParamsSchema
from core.models import Page
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(AccessMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
"""Parse and check the authentication parameters.
If parsing fails, messages will be created using the django message
infrastructure.
Returns:
The parses parameters, or None if the parsing failed.
"""
# This is here rather than in ThirdPartyAuthForm because
# the given parameters and their signature are checked during both
# POST (for obvious reasons) and GET (in order not to make
# the user fill a form just to get an error he won't understand)
params = self.request.GET if self.request.method == "GET" else self.request.POST
params = {key: unquote(val) for key, val in params.dict().items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
client: ApiClient | None = get_object_or_none(ApiClient, id=params.client_id)
if not client:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
messages.error(
self.request,
_(
"The signature is incorrect. "
"We cannot ensure the provenance of the request."
),
)
return None
return params
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if (params := self.parse_params()) is None:
# if parameters parsing failed, shortcut the operation and display
# an empty page with just the error messages.
return render(request, "core/base.jinja")
self.params = params
return super().dispatch(request, *args, **kwargs)
def get(self, *args, **kwargs):
messages.warning(
self.request,
_(
"You are going to link your AE account and your %(app)s account. "
"Continue only if this page was opened from %(app)s."
)
% {"app": self.params.third_party_app},
)
return super().get(*args, **kwargs)
def get_initial(self):
return self.params.model_dump()
def form_valid(self, form):
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
user = UserProfileSchema.from_orm(self.request.user).model_dump()
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
try:
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
except requests.RequestException as e:
sentry_sdk.capture_exception(e)
ok = False
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if ok else "failure"},
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"third_party_app": self.params.third_party_app,
"third_party_cgu": self.params.privacy_link,
"sith_cgu": Page.objects.get(_full_name=settings.SITH_CGU_PAGE),
}
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
"""View that the user will see if its authentication on sith was successful.
This can show either a success or a failure message :
- success : everything is good, the user is successfully authenticated
and can close the page
- failure : the authentication has been processed on the sith side,
but the request to the callback url received an error.
In such a case, there is nothing much we can do but to advice
the user to contact the developers of the third-party app.
"""
template_name = "core/base.jinja"
success_message = _(
"You have been successfully authenticated. You can now close this page."
)
error_message = _(
"Your authentication on the AE website was successful, "
"but an error happened during the interaction "
"with the third-party application. "
"Please contact the managers of the latter."
)
def get(self, request, *args, **kwargs):
if self.kwargs.get("result") == "success":
messages.success(request, self.success_message)
else:
messages.error(request, self.error_message)
return super().get(request, *args, **kwargs)
+20 -6
View File
@@ -7,23 +7,37 @@
},
"files": {
"ignoreUnknown": false,
"ignore": ["*.min.*", "staticfiles/generated"]
"includes": ["**/static/**", "vite.config.mts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"lineWidth": 88
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"all": true
"preset": "recommended",
"style": {
"useNamingConvention": "error"
},
"performance": {
"noNamespaceImport": "error"
},
"suspicious": {
"noConsole": {
"level": "error",
"options": { "allow": ["error", "warn"] }
}
},
"correctness": {
"noUnusedVariables": "error",
"noUndeclaredVariables": "error",
"noUndeclaredDependencies": "error"
}
}
},
"javascript": {
"globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"]
"globals": ["Alpine", "gettext", "interpolate"]
}
}
+47 -1
View File
@@ -12,9 +12,15 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import TYPE_CHECKING
from django.contrib import admin
from club.models import Club, 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)
@@ -29,6 +35,31 @@ class ClubAdmin(admin.ModelAdmin):
"page",
)
def save_model(
self,
request: HttpRequest,
obj: Club,
form: ModelForm,
change: bool, # noqa: FBT001
):
super().save_model(request, obj, form, change)
if not change:
obj.create_default_roles()
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name", "club__name")
autocomplete_fields = ("club", "linked_groups")
list_select_related = ("club",)
list_filter = (
"is_board",
"is_presidency",
("club", admin.RelatedOnlyFieldListFilter),
)
show_facets = admin.ModelAdmin.show_facets.ALWAYS
@admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin):
@@ -40,3 +71,18 @@ class MembershipAdmin(admin.ModelAdmin):
"club__name",
)
autocomplete_fields = ("user",)
@admin.register(LinkType)
class LinkTypeAdmin(admin.ModelAdmin):
list_display = ("name", "url_base", "icon")
search_fields = ("name",)
@admin.register(ClubLink)
class ClubLinkAdmin(admin.ModelAdmin):
list_display = ("link_type", "club", "url")
list_select_related = ("link_type", "club")
autocomplete_fields = ("link_type", "club")
search_fields = ("link_type__name", "url")
list_filter = ("link_type", ("club", admin.RelatedOnlyFieldListFilter))
+72 -7
View File
@@ -6,9 +6,16 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm
from api.permissions import CanView, HasPerm
from club.models import Club, Membership
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
from club.schemas import (
ClubSchema,
ClubSearchFilterSchema,
MembershipFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
from core.models import User
@api_controller("/club")
@@ -16,13 +23,11 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]):
return filters.filter(Club.objects.all())
return filters.filter(Club.objects.order_by("name")).values()
@route.get(
"/{int:club_id}",
@@ -33,8 +38,68 @@ class ClubController(ControllerBase):
)
def fetch_club(self, club_id: int):
prefetch = Prefetch(
"members", queryset=Membership.objects.ongoing().select_related("user")
"members",
queryset=Membership.objects.ongoing().select_related("user", "role"),
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id
Club.objects.prefetch_related(prefetch, "links"), id=club_id
)
@api_controller("/user/{int:user_id}/club")
class UserClubController(ControllerBase):
@route.get(
"",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanView],
url_name="fetch_user_clubs",
)
def fetch_user_clubs(self, user_id: int):
"""Get all the active memberships of the given user."""
user = self.get_object_or_exception(User, id=user_id)
return (
Membership.objects.ongoing()
.filter(user=user)
.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")
+222 -27
View File
@@ -21,15 +21,24 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import itertools
from operator import attrgetter
from django import forms
from django.conf import settings
from django.db.models import Exists, OuterRef, Q
from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models.functions import Lower
from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership
from club.models import (
Club,
ClubLink,
ClubRole,
Mailing,
MailingSubscription,
Membership,
)
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
@@ -37,6 +46,58 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling
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):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubLink
fields = ["url", "name", "link_type"]
widgets = {
"url": forms.URLInput(
{"pattern": "https://.*", "placeholder": "https://monlien.com"}
),
"link_type": forms.HiddenInput(),
}
ClubLinkFormSet = forms.inlineformset_factory(
Club, ClubLink, ClubLinkForm, extra=0, can_delete_extra=False
)
class ClubEditForm(forms.ModelForm):
@@ -48,6 +109,20 @@ class ClubEditForm(forms.ModelForm):
fields = ["address", "logo", "short_description"]
widgets = {"short_description": forms.Textarea()}
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.link_formset = ClubLinkFormSet(
*args, instance=self.instance, prefix="link", **kwargs
)
def is_valid(self):
return super().is_valid() and self.link_formset.is_valid()
def save(self, commit=True): # noqa: FBT002
res = super().save(commit=commit)
self.link_formset.save(commit=commit)
return res
class ClubAdminEditForm(ClubEditForm):
admin_fields = ["name", "parent", "is_active"]
@@ -191,6 +266,18 @@ class SellingsForm(forms.Form):
required=False,
)
def to_filter_schema(self) -> SaleFilterSchema:
products = (
*self.cleaned_data["products"],
*self.cleaned_data["archived_products"],
)
return SaleFilterSchema(
after=self.cleaned_data["begin_date"],
before=self.cleaned_data["end_date"],
counters={c.id for c in self.cleaned_data["counters"]} or None,
products={p.id for p in products} or None,
)
class ClubOldMemberForm(forms.Form):
members_old = forms.ModelMultipleChoiceField(
@@ -202,9 +289,7 @@ class ClubOldMemberForm(forms.Form):
def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
class ClubMemberForm(forms.ModelForm):
@@ -222,19 +307,14 @@ class ClubMemberForm(forms.ModelForm):
self.request_user = request_user
self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs)
self.fields["role"].required = True
self.fields["role"].choices = [
(value, name)
for value, name in settings.SITH_CLUB_ROLES.items()
if value <= self.max_available_role
]
self.fields["role"].queryset = self.available_roles
self.instance.club = club
@property
def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
def available_roles(self) -> QuerySet[ClubRole]:
"""The roles that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses
return -1 # pragma: no cover
return ClubRole.objects.none() # pragma: no cover
class ClubAddMemberForm(ClubMemberForm):
@@ -245,21 +325,22 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser}
@cached_property
def max_available_role(self):
"""The greatest role that will be obtainable with this form.
def available_roles(self):
"""The roles that will be obtainable with this form.
Admins and the club president can attribute any role.
Board members can attribute roles lower than their own.
Other users cannot attribute roles with this form
"""
qs = self.club.roles.filter(is_active=True)
if self.request_user.has_perm("club.add_membership"):
return settings.SITH_CLUB_ROLES_ID["President"]
return qs.all()
membership = self.request_user_membership
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return -1
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
if membership is None or not membership.role.is_board:
return ClubRole.objects.none()
if membership.role.is_presidency:
return qs.all()
return qs.above_instance(membership.role)
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
@@ -283,13 +364,11 @@ class JoinClubForm(ClubMemberForm):
def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_user, **kwargs)
# this form doesn't manage the user who will join the club,
# so we must set this here to avoid errors
self.instance.user = self.request_user
@cached_property
def max_available_role(self):
return settings.SITH_MAXIMUM_FREE_ROLE
def available_roles(self):
return self.club.roles.filter(is_board=False, is_active=True)
def clean(self):
"""Check that the user is subscribed and isn't already in the club."""
@@ -302,3 +381,119 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid"
)
return super().clean()
class ClubSearchForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name"]
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
club_status = forms.NullBooleanField(
label=_("Club status"),
widget=forms.RadioSelect(
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
),
initial=True,
)
def __init__(self, *args, data: dict | None = None, **kwargs):
super().__init__(*args, data=data, **kwargs)
if data is not None and "club_status" not in data:
# if the key is missing, it is considered as None,
# even though we want the default True value to be applied in such a case
# so we enforce it.
self.fields["club_status"].value = True
self.fields["name"].required = False
class ClubRoleForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
widgets = {
"is_presidency": forms.HiddenInput(),
"is_board": forms.HiddenInput(),
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
}
def clean(self):
cleaned_data = super().clean()
if "ORDER" in cleaned_data:
self.instance.order = cleaned_data["ORDER"] - 1
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):
"""Form to create a club role.
Notes:
For UX purposes, users are not meant to fill `is_presidency`
and `is_board`, so those values are required by the form constructor
in order to initialize the instance properly.
"""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description"]
def __init__(
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
):
super().__init__(*args, **kwargs)
self.instance.club = club
self.instance.is_presidency = is_presidency
self.instance.is_board = is_board
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput()
def __init__(self, *args, queryset=None, **kwargs):
if queryset is None:
queryset = self.model._default_manager
super().__init__(
*args, queryset=queryset.prefetch_related("linked_groups"), **kwargs
)
ClubRoleFormSet = forms.inlineformset_factory(
Club,
ClubRole,
ClubRoleForm,
ClubRoleBaseFormSet,
can_delete=False,
can_order=True,
edit_only=True,
extra=0,
)
@@ -1,13 +1,20 @@
# 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.functions.datetime
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
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
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs.
@@ -46,10 +53,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
).select_related("user")
club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set(
[
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
)
@@ -0,0 +1,165 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = {
10: "Président⸱e",
9: "Vice-Président⸱e",
7: "Trésorier⸱e",
5: "Responsable communication",
4: "Secrétaire",
3: "Responsable info",
2: "Membre du bureau",
1: "Membre actif⸱ve",
0: "Curieux⸱euse",
}
def migrate_roles(apps: StateApps, schema_editor):
ClubRole = apps.get_model("club", "ClubRole")
Membership = apps.get_model("club", "Membership")
updates = []
for club_id, role in Membership.objects.values_list("club", "role").distinct():
new_role = ClubRole.objects.create(
name=SITH_CLUB_ROLES[role],
is_board=role > MAXIMUM_FREE_ROLE,
is_presidency=role in PRESIDENCY_ROLES,
club_id=club_id,
order=max(SITH_CLUB_ROLES) - role,
)
updates.append(When(club_id=club_id, role=role, then=new_role.id))
# all updates must happen at the same time
# otherwise, the 10 first created ClubRole would be
# re-modified after their initial creation, and it would
# result in an incoherent state.
# To avoid that, all updates are wrapped in a single giant Case(When) statement
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
Membership.objects.update(role=Case(*updates))
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("core", "0047_alter_notification_date_alter_notification_type"),
]
operations = [
migrations.AlterField(
model_name="club",
name="page",
field=models.OneToOneField(
blank=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.page",
),
),
migrations.CreateModel(
name="ClubRole",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"order",
models.PositiveIntegerField(
db_index=True, editable=False, verbose_name="order"
),
),
(
"club",
models.ForeignKey(
help_text="The club with which this role is associated",
on_delete=django.db.models.deletion.CASCADE,
related_name="roles",
to="club.club",
verbose_name="club",
),
),
("name", models.CharField(max_length=50, verbose_name="name")),
(
"description",
models.TextField(
default="", blank=True, verbose_name="description"
),
),
(
"is_board",
models.BooleanField(default=False, verbose_name="Board role"),
),
(
"is_presidency",
models.BooleanField(default=False, verbose_name="Presidency role"),
),
(
"is_active",
models.BooleanField(
default=True,
help_text=(
"If the role is inactive, people joining the club "
"won't be able to get it."
),
verbose_name="is active",
),
),
],
options={
"ordering": ("order",),
"verbose_name": "club role",
"verbose_name_plural": "club roles",
},
),
migrations.AlterField(
model_name="club",
name="board_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AlterField(
model_name="club",
name="members_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.AddConstraint(
model_name="clubrole",
constraint=models.CheckConstraint(
condition=models.Q(
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_board",
violation_error_message=(
"A role cannot be in the presidency while not being in the board"
),
),
),
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The constraint is created in the next migration
]
@@ -0,0 +1,25 @@
# Generated by Django 5.2.3 on 2025-09-27 09:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0015_clubrole_alter_membership_role")]
operations = [
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The data migration was made in the previous migration.
migrations.AlterField(
model_name="membership",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="members",
to="club.clubrole",
verbose_name="role",
),
),
]
+104
View File
@@ -0,0 +1,104 @@
# Generated by Django 5.2.12 on 2026-04-27 07:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0016_clubrole_alter_membership_role")]
operations = [
migrations.CreateModel(
name="LinkType",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=40, verbose_name="name")),
(
"url_base",
models.URLField(
help_text=(
"The base url that links with this type must respect"
),
unique=True,
verbose_name="url base",
),
),
(
"icon",
models.CharField(
help_text=(
"The fontawesome class to use "
"(e.g. `fa-brands fa-instagram`)"
),
max_length=40,
verbose_name="icon",
),
),
],
options={"verbose_name": "link type", "verbose_name_plural": "link types"},
),
migrations.CreateModel(
name="ClubLink",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(blank=True, max_length=40, verbose_name="name"),
),
("url", models.URLField(verbose_name="link url")),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.club",
verbose_name="club",
),
),
(
"link_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.linktype",
verbose_name="link type",
),
),
],
options={
"verbose_name": "club link",
"verbose_name_plural": "club links",
"constraints": [
models.UniqueConstraint(
fields=["club", "url"],
name="club_clublink_unique_club_url",
violation_error_message="Duplicated url",
)
],
},
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.2.17 on 2026-09-01 14:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0017_linktype_clublink"),
("core", "0050_alter_sithfile_moderator"),
]
operations = [
migrations.AddField(
model_name="clubrole",
name="linked_groups",
field=models.ManyToManyField(
help_text=(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="linked_roles",
to="core.group",
verbose_name="Linked groups",
),
),
]
+282 -89
View File
@@ -23,21 +23,22 @@
#
from __future__ import annotations
import operator
from functools import reduce
from typing import Iterable, Self
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.db import ProgrammingError, models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
@@ -90,13 +91,13 @@ class Club(models.Model):
on_delete=models.SET_NULL,
)
page = models.OneToOneField(
Page, related_name="club", blank=True, on_delete=models.CASCADE
Page, related_name="club", blank=True, on_delete=models.PROTECT
)
members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT
Group, related_name="club", on_delete=models.PROTECT, editable=False
)
board_group = models.OneToOneField(
Group, related_name="club_board", on_delete=models.PROTECT
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
)
objects = ClubQuerySet.as_manager()
@@ -139,9 +140,7 @@ class Club(models.Model):
@cached_property
def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club."""
return self.members.filter(
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first()
return self.members.filter(end_date=None).order_by("role__order").first()
def check_loop(self):
"""Raise a validation error when a loop is found within the parent list."""
@@ -186,10 +185,41 @@ class Club(models.Model):
self.page.parent = self.parent.page
self.page.save(force_lock=True)
def create_default_roles(self):
"""Create some roles that should exist by default for this club.
The created roles are : president, treasurer, active member and curious.
Warnings:
When calling this method, no club must exist yet for this club.
"""
if self.roles.exists():
raise ProgrammingError(
"Default roles can be created only for clubs "
"that don't have associated roles yet"
)
# The names are written in French, because there is no gettext involved
# for strings stored in database, and the majority of users are french.
roles = [
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
ClubRole(
name="Curieux⸱euse",
description=(
"Les gens qui suivent l'activité "
"du club sans forcément y participer"
),
is_board=False,
is_presidency=False,
),
]
for i, role in enumerate(roles):
role.club = self
role.order = i
ClubRole.objects.bulk_create(roles)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}")
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
@@ -210,29 +240,129 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user.
def can_roles_be_edited_by(self, user: User) -> bool:
"""Return True if the given user can edit the roles of this club"""
return user.is_authenticated and (
user.has_perm("club.change_clubrole")
or self.members.ongoing()
.filter(user=user, role__is_presidency=True)
.exists()
)
Note:
The result is cached.
"""
@cached_property
def current_members(self) -> list[Membership]:
return list(
self.members.ongoing().select_related("user", "role").order_by("-role")
)
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user."""
if user.is_anonymous:
return None
membership = cache.get(f"membership_{self.id}_{user.id}")
if membership == "not_member":
return None
if membership is None:
membership = self.members.filter(user=user, end_date=None).first()
if membership is None:
cache.set(f"membership_{self.id}_{user.id}", "not_member")
else:
cache.set(f"membership_{self.id}_{user.id}", membership)
return membership
return next((m for m in self.current_members if m.user_id == user.id), None)
def has_rights_in_club(self, user: User) -> bool:
return user.is_in_group(pk=self.board_group_id)
class ClubRole(OrderedModel):
club = models.ForeignKey(
Club,
verbose_name=_("club"),
help_text=_("The club with which this role is associated"),
related_name="roles",
on_delete=models.CASCADE,
)
name = models.CharField(_("name"), max_length=50)
description = models.TextField(_("description"), blank=True, default="")
is_board = models.BooleanField(_("Board role"), default=False)
is_presidency = models.BooleanField(_("Presidency role"), default=False)
is_active = models.BooleanField(
_("is active"),
default=True,
help_text=_(
"If the role is inactive, people joining the club won't be able to get it."
),
)
linked_groups = models.ManyToManyField(
Group,
verbose_name=_("Linked groups"),
help_text=_(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="linked_roles",
)
order_with_respect_to = "club"
class Meta(OrderedModel.Meta):
verbose_name = _("club role")
verbose_name_plural = _("club roles")
constraints = [
# presidency IMPLIES board <=> NOT presidency OR board
# cf. MT1 :)
models.CheckConstraint(
condition=Q(is_presidency=False) | Q(is_board=True),
name="clubrole_presidency_implies_board",
violation_error_message=_(
"A role cannot be in the presidency while not being in the board"
),
)
]
def __str__(self):
return self.name
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def clean(self):
errors = []
roles = list(self.club.roles.all())
if (
self.is_board
and self.order
and any(r.order < self.order and not r.is_board for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a member role")
% {"role": self.name}
)
)
if (
self.is_presidency
and self.order
and any(r.order < self.order and not r.is_presidency for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a non-presidency role")
% {"role": self.name}
)
)
if errors:
raise ValidationError(errors)
return super().clean()
def save(self, *args, **kwargs):
auto_order = self.order is None and self.is_board
if not auto_order:
super().save(*args, **kwargs)
return
# get the role that should be placed after the role we are dealing with.
# So, if this is role is presidency, get the first board role ;
# if it is a board role, get the first member role ;
# and if it is a member role, get nothing (OrderedModel.save will
# automatically put it in the last position anyway)
filters = {"is_board": self.is_presidency, "is_presidency": False}
next_role = self.club.roles.filter(**filters).order_by("order").first()
super().save(*args, **kwargs)
if next_role:
self.above(next_role)
class MembershipQuerySet(models.QuerySet):
def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet."""
@@ -245,9 +375,10 @@ class MembershipQuerySet(models.QuerySet):
are included, even if there are no more members.
If you want to get the users who are currently in the board,
mind combining this with the :meth:`ongoing` queryset method
mind combining this with the [MembershipQuerySet.ongoing][]
queryset method
"""
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
return self.filter(role__is_board=True)
def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit.
@@ -270,60 +401,42 @@ class MembershipQuerySet(models.QuerySet):
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.filter(
return self.ongoing().filter(
Q(user=user)
| Exists(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
Membership.objects.ongoing().filter(
user=user,
end_date=None,
club=OuterRef("club"),
role__is_board=True,
role__order__lt=OuterRef("role__order"),
)
),
end_date=None,
)
)
def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership.
Update the cache, when necessary, remove
users from club groups they are no more in
"""Remove users from club groups they are no more in
and add them in the club groups they should be in.
Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
- one to retrieve the updated memberships
- one to perform group removal
- and one to perform group attribution.
"""
nb_rows = super().update(**kwargs)
if nb_rows == 0:
# if no row was affected, no need to refresh the cache
# if no row was affected, no need to edit club groups
return 0
cache_memberships = {}
memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones
# It's more concise to write and more reliable
Membership._remove_club_groups(memberships)
Membership._add_club_groups(memberships)
for member in memberships:
cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else:
cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships)
return nb_rows
def delete(self) -> tuple[int, dict[str, int]]:
"""Work just like the default Django's delete() method,
but add a cache invalidation for the elements of the queryset
before the deletion,
and a removal of the user from the club groups.
but also remove the concerned users from the club groups.
Be aware that this adds some db queries :
@@ -339,12 +452,6 @@ class MembershipQuerySet(models.QuerySet):
nb_rows, rows_counts = super().delete()
if nb_rows > 0:
Membership._remove_club_groups(memberships)
cache.set_many(
{
f"membership_{m.club_id}_{m.user_id}": "not_member"
for m in memberships
}
)
return nb_rows, rows_counts
@@ -373,10 +480,11 @@ class Membership(models.Model):
)
start_date = models.DateField(_("start date"), default=timezone.now)
end_date = models.DateField(_("end date"), null=True, blank=True)
role = models.IntegerField(
_("role"),
choices=sorted(settings.SITH_CLUB_ROLES.items()),
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
role = models.ForeignKey(
ClubRole,
verbose_name=_("role"),
related_name="members",
on_delete=models.PROTECT,
)
description = models.CharField(
_("description"), max_length=128, null=False, blank=True
@@ -394,7 +502,7 @@ class Membership(models.Model):
def __str__(self):
return (
f"{self.club.name} - {self.user.username} "
f"- {settings.SITH_CLUB_ROLES[self.role]} "
f"- {self.role.name} "
f"- {str(_('past member')) if self.end_date is not None else ''}"
)
@@ -408,9 +516,6 @@ class Membership(models.Model):
self._remove_club_groups([self])
if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id})
@@ -426,18 +531,21 @@ class Membership(models.Model):
if user.is_root or user.is_board_member:
return True
membership = self.club.get_membership_for(user)
return membership is not None and membership.role >= self.role
if not membership:
return False
return membership.user_id == user.id or (
membership.is_board and membership.role.order < self.role.order
)
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(
memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club groups.
"""Remove users of those memberships from the club and club role groups.
For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll.
@@ -456,15 +564,19 @@ class Membership(models.Model):
clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
role_groups = [
Q(user_id=m.user_id, group__linked_roles=m.role_id) for m in memberships
]
return User.groups.through.objects.filter(
Q(group__in=groups) & Q(user__in=users)
(Q(group__in=groups) & Q(user__in=users))
| reduce(operator.or_, role_groups)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> list[User.groups.through]:
"""Add users of those memberships to the club groups.
"""Add users of those memberships to the club and club role groups.
For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group
@@ -485,34 +597,40 @@ class Membership(models.Model):
memberships = [m for m in memberships if m.end_date is None]
if not memberships:
return []
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
nb_prefetched = sum(
1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role")
)
if nb_prefetched > 1:
# if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with
# a select_related than perform a distinct query
# to fetch each club.
ids = {m.id for m in memberships}
memberships = list(
Membership.objects.filter(id__in=ids).select_related("club")
Membership.objects.filter(id__in=ids)
.select_related("club", "role")
.prefetch_related("role__linked_groups")
)
club_groups = []
groups = []
for membership in memberships:
club_groups.append(
groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_groups.append(
if membership.role.is_board:
groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.board_group_id,
)
)
return User.groups.through.objects.bulk_create(
club_groups, ignore_conflicts=True
)
groups.extend(
User.groups.through(user_id=membership.user_id, group_id=g.id)
for g in membership.role.linked_groups.all()
)
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
class Mailing(models.Model):
@@ -676,3 +794,78 @@ class MailingSubscription(models.Model):
def fetch_format(self):
return self.get_email + " "
class LinkType(models.Model):
"""A link type, in order to group links and give them icons.
Notes:
Among all club links, there is a special one, with an empty base url
and a default link icon.
It is use as a fallback item when no actual link type can be found.
Danger:
LinkType.icon is content that will be raw-rendered in the template.
It is NOT safe to allow users to give it.
The edition of this field must be reserved to trusted admins.
"""
name = models.CharField(_("name"), max_length=40)
url_base = models.URLField(
"url base",
unique=True,
help_text=_("The base url that links with this type must respect"),
)
icon = models.CharField(
_("icon"),
max_length=40,
help_text=_("The fontawesome class to use (e.g. `fa-brands fa-instagram`)"),
)
class Meta:
verbose_name = _("link type")
verbose_name_plural = _("link types")
def __str__(self):
return self.name
class ClubLink(models.Model):
link_type = models.ForeignKey(
LinkType,
verbose_name=_("link type"),
on_delete=models.CASCADE,
related_name="links",
)
name = models.CharField(_("name"), max_length=40, blank=True)
url = models.URLField(_("link url"))
club = models.ForeignKey(
Club, verbose_name=_("club"), on_delete=models.CASCADE, related_name="links"
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("club link")
verbose_name_plural = _("club links")
constraints = [
models.UniqueConstraint(
fields=["club", "url"],
name="club_clublink_unique_club_url",
violation_error_message=_("Duplicated url"),
)
]
def __str__(self):
return self.url
def save(self, **kwargs):
if not self.name:
self.name = self.link_type.name
return super().save(**kwargs)
def clean(self):
if not self.url.startswith(self.link_type.url_base):
raise ValidationError(
_("This link doesn't match with the url base of its type.")
)
+39 -8
View File
@@ -1,18 +1,18 @@
from datetime import date
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema
from ninja import FilterLookup, FilterSchema, ModelSchema
from pydantic import HttpUrl
from club.models import Club, Membership
from core.schemas import SimpleUserSchema
from club.models import Club, ClubRole, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
is_active: bool | None = None
parent_id: int | None = None
parent_name: str | None = Field(None, q="parent__name__icontains")
exclude_ids: set[int] | None = None
def filter_exclude_ids(self, value: set[int] | None):
@@ -32,7 +32,7 @@ class ClubProfileSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name", "logo"]
fields = ["id", "name", "logo", "is_active", "short_description"]
url: str
@@ -41,12 +41,21 @@ class ClubProfileSchema(ModelSchema):
return obj.get_absolute_url()
class ClubRoleSchema(ModelSchema):
class Meta:
model = ClubRole
fields = ["id", "name", "is_presidency", "is_board"]
class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club."""
class Meta:
model = Membership
fields = ["start_date", "end_date", "role", "description"]
fields = ["start_date", "end_date", "description"]
user: SimpleUserSchema
role: ClubRoleSchema
class ClubSchema(ModelSchema):
@@ -55,3 +64,25 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]
links: list[HttpUrl]
@staticmethod
def resolve_links(obj: Club):
return [link.url for link in obj.links.all()]
class UserMembershipSchema(ModelSchema):
"""A schema to represent the active club memberships of a user."""
class Meta:
model = Membership
fields = ["id", "start_date", "description"]
club: SimpleClubSchema
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 { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
@@ -10,7 +9,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
protected async search(query: string) {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
@@ -0,0 +1,61 @@
import type { AlpineComponent } from "alpinejs";
interface RoleGroupData {
isBoard: boolean;
isPresidency: boolean;
roleId: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
confirmOnSubmit: false,
/**
* Edit relevant item data after it has been moved by x-sort
*/
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
item.isBoard = conf.isBoard;
item.isPresidency = conf.isPresidency;
// if the user has moved its own role outside the presidency,
// submitting the form will require a confirmation
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
this.resetOrder();
},
/**
* Reset the value of the ORDER input of all items in the list.
* This is to be called after any reordering operation, in order to make sure
* that the order that will be saved is coherent with what is displayed.
*/
resetOrder() {
// When moving items with x-sort, the only information we truly have is
// the end position in the target group, not the previous position nor
// the position in the global list.
// To overcome this, we loop through an enumeration of all inputs
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
const inputs = document.querySelectorAll<HTMLInputElement>(
"input[name^='roles'][name$='ORDER']",
);
for (const [i, elem] of inputs.entries()) {
elem.value = (i + 1).toString();
}
},
/**
* If the user moved its role out of the presidency, ask a confirmation
* before submitting the form
*/
confirmSubmission(event: SubmitEvent) {
if (
this.confirmOnSubmit &&
!confirm(
gettext(
"You're going to remove your own role from the presidency. " +
"You may lock yourself out of this page. Do you want to continue ? ",
),
)
) {
event.preventDefault();
}
},
}));
});
+66
View File
@@ -0,0 +1,66 @@
#club-detail {
img.club-logo {
display: block;
max-height: 200px;
max-width: 200px;
}
#club-attributes {
ul {
list-style: none;
margin-left: 0;
display: flex;
flex-direction: column;
gap: .75rem;
li i {
margin-right: .5rem;
}
}
}
&:not(.has-links) {
#club-attributes {
float: right;
margin: 1em 0 1em 2em;
@media screen and (max-width: 650px) {
margin-left: 1em;
}
@media screen and (max-width: 400px) {
float: unset;
img.club-logo {
margin: auto;
}
}
}
}
&.has-links {
display: flex;
flex-direction: row-reverse;
gap: 2em;
@media screen and (max-width: 650px) {
flex-direction: column;
gap: 1em;
}
#club-attributes {
display: flex;
flex-direction: column;
gap: 1em;
min-width: 200px;
@media screen and (max-width: 650px) {
margin-top: 1em;
flex-direction: row-reverse;
justify-content: flex-end;
h4 {
margin: 0;
}
img.club-logo {
margin-left: auto;
}
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
#club-list {
display: flex;
flex-direction: column;
gap: 2em;
padding: 2em;
.card {
display: block;
background-color: unset;
.club-image {
float: left;
margin-right: 2rem;
margin-bottom: .5rem;
width: 150px;
height: 150px;
border-radius: 10%;
background-color: rgba(173, 173, 173, 0.2);
@media screen and (max-width: 500px) {
width: 100px;
height: 100px;
}
}
i.club-image {
display: flex;
flex-direction: column;
justify-content: center;
color: black;
}
.content {
display: block;
text-align: justify;
h4 {
margin-top: 0;
margin-right: .5rem;
}
p {
font-size: 100%;
}
}
}
}
@media screen and (max-width: 575px){
#club-list{
padding-left: 0;
padding-right: 0;
}
}
+7
View File
@@ -0,0 +1,7 @@
.fa-grip-vertical {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
margin-right: .5em;
}
+36 -9
View File
@@ -21,16 +21,43 @@
{% endif %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/detail.scss") }}">
{% endblock %}
{% block content %}
<div id="club_detail">
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %}
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{{ club.name }}</h3>
{% endif %}
<h3>{{ club.name }}</h3>
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
<div id="club-attributes">
{% if club.logo %}
<img
class="club-logo"
src="{{ club.logo.url }}"
alt="{{ club.name }}"
width="{{ club.logo.width }}"
height="{{ club.logo.height }}"
>
{% endif %}
{% if links %}
<div id="club-links">
<h4>{% trans %}Links{% endtrans %}</h4>
<ul>
{% for link in links %}
<li>
<a href="{{ link.url }}" rel="noopener external" target="_blank">
<i class="{{ link.link_type.icon }} fa-xl"></i>{{ link.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div id="club-page">
{% if page_revision %}
{{ page_revision|markdown }}
{% endif %}
</div>
</div>
{% endblock %}
+89 -41
View File
@@ -1,52 +1,100 @@
{% extends "core/base.jinja" %}
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Liste des clubs et assos" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%}
{% if club.is_active or user.is_root %}
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
{% if not club.is_active %}
({% trans %}inactive{% endtrans %})
{% endif %}
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% endif %}
{% block additional_css %}
{% if not is_fragment %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endif %}
{% endblock %}
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{%- endif -%}
</li>
{%- endmacro %}
{% from "core/macros.jinja" import paginate_htmx %}
{% block content %}
{% if user.is_root %}
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
{% endif %}
{% if club_list %}
<main>
<h3>{% trans %}Filters{% endtrans %}</h3>
<form
id="club-list-filters"
method="GET"
hx-action="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="innerHTML"
hx-push-url="true"
hx-disable="find input, find button"
>
<div class="row gap-4x">
{{ form }}
</div>
<button class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button>
</form>
<h3>{% trans %}Club list{% endtrans %}</h3>
<ul>
{%- for club in club_list %}
{{ display_club(club) }}
{%- endfor %}
</ul>
{% else %}
{% trans %}There is no club in this website.{% endtrans %}
{% endif %}
{% if user.has_perm("club.add_club") %}
<br>
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
</a>
{% endif %}
<section class="aria-busy-grow" id="club-list">
{% for club in object_list %}
<div class="card">
{% set club_url = club.get_absolute_url() %}
<a href="{{ club_url }}">
{% if club.logo %}
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
{% else %}
<i class="fa-regular fa-image fa-4x club-image"></i>
{% endif %}
</a>
<div class="content">
<a href="{{ club_url }}">
<h4>
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
{% set links = club.links.all() %}
{% if links %}
<br>
<div class="row gap-2x">
{% for link in club.links.all() %}
<a href="{{ link.url }}" rel="noopener external" target="_blank">
<i class="{{ link.link_type.icon }} fa-xl"></i>
<strong>{{ link.name }}</strong>
</a>
{% endfor %}
</div>
{% endif %}
{{ club.short_description|markdown }}
</div>
</div>
{% endfor %}
</section>
{% if is_paginated %}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
</main>
{% endblock %}
+10 -5
View File
@@ -1,11 +1,7 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
{% endblock %}
@@ -16,6 +12,15 @@
<h2>{% trans %}Club members{% endtrans %}</h2>
{% if club.can_roles_be_edited_by(user) %}
<a
href="{{ url("club:club_roles", club_id=object.id) }}"
class="btn btn-blue margin-bottom"
>
<i class="fa fa-users-gear"></i> {% trans %}Manage roles{% endtrans %}
</a>
{% endif %}
{% if add_member_fragment %}
<br />
{{ add_member_fragment }}
@@ -45,7 +50,7 @@
{% for m in members %}
<tr>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.role.name }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{%- if can_end_membership -%}
+1 -1
View File
@@ -17,7 +17,7 @@
{% for member in old_members %}
<tr>
<td>{{ user_profile_link(member.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ member.role.name }}</td>
<td>{{ member.description }}</td>
<td>{{ member.start_date }}</td>
<td>{{ member.end_date }}</td>
+186
View File
@@ -0,0 +1,186 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}" xmlns="http://www.w3.org/1999/html"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
{% endblock %}
{% macro display_subform(subform) %}
<div
class="row"
x-data="{
isPresidency: {{ subform.is_presidency.value()|lower }},
isBoard: {{ subform.is_board.value()|lower }},
roleId: {{ subform.id.value() }},
}"
x-sort:item="$data"
>
{# hidden fields #}
{{ subform.ORDER }}
{{ subform.id }}
{{ subform.club }}
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
{{ subform.is_board|add_attr("x-model=isBoard") }}
<i class="fa fa-grip-vertical" x-sort:handle></i>
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
<summary>
{{ subform.name.value() }}
{% if not subform.instance.is_active -%}
({% trans %}inactive{% endtrans %})
{%- endif %}
</summary>
<div class="accordion-content">
{{ subform.non_field_errors() }}
<div class="form-group">
{{ subform.name.as_field_group() }}
</div>
<div class="form-group">
{{ subform.description.as_field_group() }}
</div>
<div class="form-group">
<div>
{{ subform.is_active }}
{{ subform.is_active.label_tag() }}
</div>
<span class="helptext">
{{ subform.is_active.help_text }}
</span>
</div>
{% set groups = subform.instance.linked_groups.all()|list %}
{% if groups %}
<div>
<p>
<strong>{% trans %}Linked groups : {% endtrans %}</strong>
{{ groups|map(attribute="name")|join(", ") }}
</p>
<p class="helptext">
{% trans trimmed %}
Users receiving this role will also be assigned to those groups
{% endtrans %}
</p>
</div>
{% endif %}
</div>
</details>
</div>
{% endmacro %}
{% block content %}
<p>
{% trans trimmed %}
Roles give rights on the club.
Higher roles grant more rights, and the members having them are displayed higher
in the club members list.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
On this page, you can edit their name and description, as well as their order.
You can also drag roles from a category to another
(e.g. a board role can be made into a presidency role).
{% endtrans %}
</p>
<form
method="post"
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
@submit="confirmSubmission"
>
{% csrf_token %}
{{ form.management_form }}
{{ form.non_form_errors() }}
<h3>{% trans %}Presidency{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
{# The style we use for markdown rendering is quite nice for what we want to display,
so we are just gonna reuse it. #}
<div class="markdown">
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
<ul>
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
<li>{% trans %}manage the club counters{% endtrans %}</li>
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
</ul>
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<h3>{% trans %}Board{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>
{% trans trimmed %}
Board members can do most administrative actions in the club, including :
{% endtrans %}
</p>
<ul>
<li>{% trans %}manage the club posters{% endtrans %}</li>
<li>{% trans %}create news for the club{% endtrans %}</li>
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
<li>
{% trans trimmed %}
add new members and end active memberships
for roles that are lower than their own.
{% endtrans %}
</li>
</ul>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_board.value() and not subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<h3>{% trans %}Members{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if not subform.is_board.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<p>
<button type="submit" class="btn btn-blue">
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
</button>
</p>
</form>
{% endblock content %}
+1 -1
View File
@@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one
{% csrf_token %}
{{ form }}
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
<p><input type="submit" value="{% trans %}Download as cvs{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
<p><input type="submit" value="{% trans %}Download as CSV{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
</form>
<p>
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>
+13 -2
View File
@@ -5,8 +5,19 @@
<div>
<h4>{% trans %}Communication:{% endtrans %}</h4>
<ul>
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
<li>
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
{% trans %}Create a news{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.can_roles_be_edited_by(user) %}
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
{% endif %}
{% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %}
+100 -6
View File
@@ -1,9 +1,63 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% block title %}
{% trans name=object %}Edit {{ name }}{% endtrans %}
{% endblock %}
{% macro link_form(form) %}
<fieldset
{# set url in x-init rather than in x-data,
in order to trigger the $watch on initial load #}
x-data="{ url: '', linkType: { icon: '', id: 0 } }"
x-init="() => {
$watch('url', (u) => linkType = linkTypes.find((t) => u.startsWith(t.url)));
url = '{{ form.url.value() or "" }}';
}"
>
{{ form.non_field_errors() }}
<div class="form-group row gap-2x">
<div>
{{ form.url.label_tag() }}
{{ form.url.errors }}
<span>
{# we change the icon when the user change it and leave the input,
or when it is pasted from the clipboard #}
{{ form.url|add_attr("x-model.change=url,@paste.prevent=url = $event.clipboardData.getData('text')") }}
<i
:class="linkType.icon || 'fa fa-link'"
tooltip="{% trans %}This icon will change according to the given url.{% endtrans %}"
></i>
</span>
</div>
<div>{{ form.name.as_field_group() }}</div>
</div>
{%- if form.DELETE -%}
<div class="form-group row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<br>
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i> {% trans %}Remove link{% endtrans %}
</button>
{%- endif -%}
{{ form.link_type|add_attr(":value=linkType.id") }}
{%- for field in form.hidden_fields() -%}
{%- if field != form.link_type -%}
{{ field }}
{%- endif -%}
{%- endfor -%}
</fieldset>
{% endmacro %}
{% block content %}
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
@@ -17,7 +71,7 @@
and explicitly separate them from the non-admin ones,
with some help text.
Non-admin users will only see the regular form fields,
so they don't need thoses explanations #}
so they don't need those explanations #}
<h3>{% trans %}Club properties{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
@@ -25,7 +79,7 @@
Only admin users can see and edit them.
{% endtrans %}
</p>
<fieldset class="required margin-bottom">
<fieldset class="margin-bottom">
{% for field_name in form.admin_fields %}
{% set field = form[field_name] %}
<div class="form-group">
@@ -36,11 +90,13 @@
{# Remove the the admin fields from the form.
The remaining non-admin fields will be rendered
at once with a simple {{ form.as_p() }} #}
{% set _ = form.fields.pop(field_name) %}
{% do form.fields.pop(field_name) %}
{% endfor %}
</fieldset>
{% endif %}
<h3>{% trans %}Club informations{% endtrans %}</h3>
<h3>{% trans %}Club informations{% endtrans %}</h3>
{% if form.admin_fields %}
<p class="helptext">
{% trans trimmed %}
The following form fields are linked to the basic description of a club.
@@ -48,7 +104,45 @@
{% endtrans %}
</p>
{% endif %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
<fieldset class="margin-bottom">
{{ form.as_p() }}
</fieldset>
<h3>{% trans %}Club links{% endtrans %}</h3>
<div x-data="dynamicFormSet({ prefix: '{{ form.link_formset.prefix }}' })" class="margin-bottom">
{{ form.link_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.link_formset.forms -%}
{{ link_form(f) }}
{%- endfor -%}
</div>
<template x-ref="formTemplate">
{{ link_form(form.link_formset.empty_form) }}
</template>
<p>
<i>{% trans trimmed %}
Note: if the icon of one of your links doesn't exist yet,
you can ask the info team to add it.
{% endtrans %}</i>
</p>
<br>
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add link{% endtrans %}
</button>
</div>
<hr>
<button type="submit" class="btn btn-blue">
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
</button>
</form>
{% endblock content %}
{% block script %}
<script>
const linkTypes = [
{%- for t in link_types -%}
{ id: {{ t.id }}, url: '{{ t.url_base }}', icon: '{{ t.icon }}' },
{%- endfor -%}
];
</script>
{% endblock %}
@@ -7,7 +7,7 @@
<form
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-target="#member-fragment-container"
id="add_club_members_form"
+2 -6
View File
@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %}
{% from 'core/macros_pages.jinja' import page_history %}
{% from 'core/page/macros.jinja' import page_history %}
{% block content %}
{% if club.page %}
{{ page_history(club.page) }}
{% else %}
{% trans %}No page existing for this club{% endtrans %}
{% endif %}
{{ page_history(club.page) }}
{% endblock %}
+6 -2
View File
@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block content %}
{{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url('club:club_edit_page', club_id=page.club.id) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
+15 -5
View File
@@ -8,7 +8,7 @@ from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
@@ -43,6 +43,11 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club)
cls.president_role = baker.make(
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
)
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id}
)
@@ -51,12 +56,17 @@ class TestClub(TestCase):
yesterday = now() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=cls.club)
membership_recipe.make(
user=cls.simple_board_member, start_date=a_month_ago, role=3
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
)
membership_recipe.make(user=cls.richard, role=cls.member_role)
membership_recipe.make(
user=cls.president, start_date=a_month_ago, role=cls.president_role
)
membership_recipe.make(user=cls.richard, role=1)
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
membership_recipe.make( # sli was a member but isn't anymore
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
user=cls.sli,
start_date=a_month_ago,
end_date=yesterday,
role=cls.board_role,
)
def setUp(self):
+108 -5
View File
@@ -1,12 +1,21 @@
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@@ -16,12 +25,106 @@ def test_club_queryset_having_board_member():
membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3)
)
membership_recipe.make(club=clubs[0], role=1)
membership_recipe.make(club=clubs[1], role=3)
membership_recipe.make(club=clubs[2], role=7)
membership_recipe.make(
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
)
membership_recipe.make(
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
)
membership_recipe.make(
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
)
membership_recipe.make(
club=clubs[3],
role=baker.make(ClubRole, club=clubs[3], is_board=True),
end_date=localdate() - timedelta(days=1),
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
@pytest.mark.parametrize("is_fragment", [True, False])
@pytest.mark.django_db
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
client.force_login(baker.make(User))
baker.make(Club, _quantity=nb_additional_clubs)
headers = {"HX-Request": True} if is_fragment else {}
res = client.get(reverse("club:club_list"), headers=headers)
assert res.status_code == 200
def assert_club_created(club_name: str):
club = Club.objects.last()
assert club.name == club_name
assert club.board_group.name == f"{club_name} - Bureau"
assert club.members_group.name == f"{club_name} - Membres"
# default roles should be added on club creation,
# whether the creation happens on the admin site or on the user site
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
]
@pytest.mark.django_db
def test_create_view(admin_client: Client):
"""Test that the club creation view works well"""
res = admin_client.get(reverse("club:club_new"))
assert res.status_code == 200
res = admin_client.post(
reverse("club:club_new"),
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
)
club = Club.objects.last()
assertRedirects(res, club.get_absolute_url())
assert_club_created("foo")
@pytest.mark.django_db
def test_default_roles_for_club_with_roles_fails():
"""Test that an Error is raised if trying to create
default roles for a club that already has roles.
"""
club = baker.make(Club)
baker.make(ClubRole, club=club)
with pytest.raises(ProgrammingError):
club.create_default_roles()
@pytest.mark.django_db
class TestAdminInterface:
def test_create(self, admin_client: Client):
"""Test the creation of a club via the admin interface."""
res = admin_client.post(
reverse("admin:club_club_add"),
data={
"name": "foo",
"parent": settings.SITH_MAIN_CLUB_ID,
"address": "Rome",
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
assert_club_created("foo")
def test_change(self, admin_client: Client):
"""Test the edition of a club via the admin interface."""
club = baker.make(Club)
res = admin_client.post(
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
data={
"name": "foo",
"page": club.page_id,
"home": club.home_id,
"address": club.address,
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
club.refresh_from_db()
assert club.name == "foo"
# Club roles shouldn't be modified when editing the club on the admin interface
# This club had no roles beforehand, therefore it shouldn't have roles now.
assert not club.roles.exists()
+7 -4
View File
@@ -1,6 +1,7 @@
from datetime import date, timedelta
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
@@ -8,7 +9,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import Group, Page, User
@@ -26,8 +27,10 @@ class TestClubSearch(TestCase):
"id", flat=True
)
)
Page.objects.exclude(club=None).delete()
Membership.objects.all().delete()
ClubRole.objects.all().delete()
Club.objects.all().delete()
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make(
@@ -85,8 +88,8 @@ class TestFetchClub:
def test_fetch_club_nb_queries(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(6):
with assertNumQueries(7):
# - 4 queries for authentication
# - 2 queries for the actual data
# - 3 queries for the actual data
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200
@@ -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
+283
View File
@@ -0,0 +1,283 @@
from typing import TYPE_CHECKING
import pytest
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.forms import ClubRoleFormSet
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
def make_club():
# unittest-style tests cannot use fixture, so we create a function
# that will be callable either by a pytest fixture or inside
# a TestCase.setUpTestData method.
club = baker.make(Club)
recipe = Recipe(ClubRole, club=club, name=seq("role "))
recipe.make(
is_board=iter([True, True, False]),
is_presidency=iter([True, False, False]),
order=iter([0, 1, 2]),
_quantity=3,
_bulk_create=True,
)
return club
@pytest.fixture
def club(db):
"""A club with a presidency role, a board role and a member role"""
return make_club()
@pytest.mark.django_db
def test_order_auto(club):
"""Test that newly created roles are put in the right place."""
roles = list(club.roles.all())
# create new roles one by one (like they will be in prod)
# each new role should be placed at the end of its category
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
assert list(club.roles.order_by("order")) == [
roles[0],
role_a,
roles[1],
role_b,
roles[2],
role_c,
]
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_factory", "is_allowed"),
[
(
lambda club: baker.make(
User,
user_permissions=[Permission.objects.get(codename="change_clubrole")],
),
True,
),
( # user with presidency roles can edit the club roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(is_presidency=True).first(),
)
]
),
True,
),
( # user in the board but not in the presidency cannot edit roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(
is_presidency=False, is_board=True
).first(),
)
]
),
False,
),
(lambda _: AnonymousUser(), False),
],
)
def test_can_roles_be_edited_by(
club: Club, user_factory: Callable[[Club], User], is_allowed
):
"""Test that `Club.can_roles_be_edited_by` return the right value"""
user = user_factory(club)
assert club.can_roles_be_edited_by(user) == is_allowed
@pytest.mark.django_db
@pytest.mark.parametrize(
["route", "is_presidency", "is_board"],
[
("club:new_role_president", True, True),
("club:new_role_board", False, True),
("club:new_role_member", False, False),
],
)
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
"""Test that the role creation views work."""
club = baker.make(Club)
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
user = subscriber_user.make()
baker.make(Membership, club=club, role=role, user=user, end_date=None)
url = reverse(route, kwargs={"club_id": club.id})
client.force_login(user)
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"name": "foo"})
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
new_role = club.roles.last()
assert new_role.name == "foo"
assert new_role.is_presidency == is_presidency
assert new_role.is_board == is_board
class TestClubRoleUpdate(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = make_club()
cls.roles = list(cls.club.roles.all())
cls.user = subscriber_user.make()
baker.make(
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
)
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
def setUp(self):
self.payload = {
"roles-TOTAL_FORMS": 3,
"roles-INITIAL_FORMS": 3,
"roles-MIN_NUM_FORMS": 0,
"roles-MAX_NUM_FORMS": 1000,
"roles-0-ORDER": 1,
"roles-0-id": self.roles[0].id,
"roles-0-club": self.club.id,
"roles-0-is_presidency": True,
"roles-0-is_board": True,
"roles-0-name": self.roles[0].name,
"roles-0-description": self.roles[0].description,
"roles-0-is_active": True,
"roles-1-ORDER": 2,
"roles-1-id": self.roles[1].id,
"roles-1-club": self.club.id,
"roles-1-is_presidency": False,
"roles-1-is_board": True,
"roles-1-name": self.roles[1].name,
"roles-1-description": self.roles[1].description,
"roles-1-is_active": True,
"roles-2-ORDER": 3,
"roles-2-id": self.roles[2].id,
"roles-2-club": self.club.id,
"roles-2-is_presidency": False,
"roles-2-is_board": False,
"roles-2-name": self.roles[2].name,
"roles-2-description": self.roles[2].description,
"roles-2-is_active": True,
}
def test_view_ok(self):
"""Basic test to check that the view works."""
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
self.payload["roles-2-name"] = "foo"
res = self.client.post(self.url, data=self.payload)
assertRedirects(res, self.redirect_url)
self.roles[2].refresh_from_db()
assert self.roles[2].name == "foo"
def test_incoherent_order(self):
"""Test that placing a member role over a board role fails."""
self.payload["roles-0-ORDER"] = 4
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
f"Le rôle {self.roles[0].name} ne peut pas "
"être placé en-dessous d'un rôle de membre.",
f"Le rôle {self.roles[0].name} ne peut pas être placé "
"en-dessous d'un rôle qui n'est pas de la présidence.",
]
},
{},
{},
]
def test_change_order_ok(self):
"""Test that changing order the intended way works"""
self.payload["roles-1-ORDER"] = 3
self.payload["roles-1-is_board"] = False
self.payload["roles-2-ORDER"] = 2
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert list(self.club.roles.order_by("order")) == [
self.roles[0],
self.roles[2],
self.roles[1],
]
self.roles[1].refresh_from_db()
assert not self.roles[1].is_board
def test_non_board_presidency_is_forbidden(self):
"""Test that a role cannot be in the presidency without being in the board."""
self.payload["roles-0-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
]
},
{},
{},
]
def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency,
then it loses access to the update page."""
self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload)
assertRedirects(res, self.redirect_url)
# When the user clicked that button, it still had the right to update roles,
# so the modification should be applied
self.roles[0].refresh_from_db()
assert self.roles[0].is_presidency is False
res = self.client.get(self.url)
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)
+25 -5
View File
@@ -1,22 +1,37 @@
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_club_board_member_cannot_edit_club_properties(client: Client):
user = subscriber_user.make()
club = baker.make(Club, name="old name", is_active=True, address="old address")
baker.make(Membership, club=club, user=user, role=7)
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user)
res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}),
{"name": "new name", "is_active": False, "address": "new address"},
{
"name": "new name",
"is_active": False,
"address": "new address",
"link-TOTAL_FORMS": 0,
"link-INITIAL_FORMS": 0,
},
)
# The request should success,
# but admin-only fields shouldn't be taken into account
@@ -32,7 +47,12 @@ def test_edit_club_page_doesnt_crash(client: Client):
"""crash test for club:club_edit"""
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, club=club, user=user, role=3)
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user)
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
assert res.status_code == 200
+3 -2
View File
@@ -3,9 +3,10 @@ from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, Mailing, Membership
from club.models import Club, ClubRole, Mailing, Membership
from core.models import User
@@ -25,7 +26,7 @@ class TestMailingForm(TestCase):
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=baker.make(ClubRole, club=cls.club, is_board=True),
).save()
def test_mailing_list_add_no_moderation(self):
+228 -96
View File
@@ -1,23 +1,26 @@
from collections.abc import Callable
import itertools
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker
from model_bakery import baker, seq
from pytest_django.asserts import assertRedirects
from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
from core.models import AnonymousUser, Group, User
if TYPE_CHECKING:
from collections.abc import Callable
class TestMembershipQuerySet(TestClub):
@@ -72,39 +75,25 @@ class TestMembershipQuerySet(TestClub):
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
membership = baker.make(Membership, end_date=None, user=user, role=5)
board_role, member_role = baker.make(
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
)
membership = baker.make(
Membership, end_date=None, user=user, role=board_role, club=board_role.club
)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=1) # from board to simple member
user.memberships.update(role=member_role) # from board to simple member
assert user.groups.contains(members_group)
assert not user.groups.contains(board_group)
user.memberships.update(role=5) # from member to board
user.memberships.update(role=board_role) # from member to board
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
@@ -112,28 +101,20 @@ class TestMembershipQuerySet(TestClub):
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
mem_comptable = self.president.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
cache.set(
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
)
# should delete the subscriptions of simple_board_member and president
self.club.members.ongoing().board().delete()
for membership in (mem_skia, mem_comptable):
cached_mem = cache.get(
f"membership_{membership.club_id}_{membership.user_id}"
)
assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club = baker.make(Club)
roles = baker.make(
ClubRole,
is_board=iter([False, True]),
club=club,
_quantity=2,
_bulk_create=True,
)
memberships = baker.make(
Membership, club=club, role=iter(roles), user=user, _quantity=2
)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
@@ -149,13 +130,20 @@ class TestMembershipEditableBy(TestCase):
def setUpTestData(cls):
Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
roles = baker.make(
ClubRole,
is_presidency=itertools.cycle([True, False, False, False]),
is_board=itertools.cycle([True, True, True, False]),
order=itertools.cycle(range(4)),
club=iter(
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
),
_quantity=8,
_bulk_create=True,
)
cls.memberships = [
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
),
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
]
def test_admin_user(self):
@@ -177,7 +165,7 @@ class TestMembershipEditableBy(TestCase):
class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: int):
def assert_membership_started_today(self, user: User, role: ClubRole):
"""Assert that the given membership is active and started today."""
membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None
@@ -226,21 +214,27 @@ class TestMembership(TestClub):
"Marquer comme ancien",
]
rows = table.find("tbody").find_all("tr")
memberships = self.club.members.ongoing().order_by("-role")
for row, membership in zip(
rows, memberships.select_related("user"), strict=False
):
memberships = (
self.club.members.ongoing()
.order_by("role__order")
.select_related("user", "role")
)
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
for row, membership in zip(rows, memberships, strict=False):
user = membership.user
user_url = reverse("core:user_profile", args=[user.id])
cols = row.find_all("td")
user_link = cols[0].find("a")
assert user_link.attrs["href"] == user_url
assert user_link.text == user.get_display_name()
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
assert cols[1].text == membership.role.name
assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date)
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
if (
membership.role.order > user_role.order
or membership.user_id == self.simple_board_member.id
):
# 3 is the role of simple_board_member
form_input = cols[4].find("input")
expected_attrs = {
@@ -256,14 +250,15 @@ class TestMembership(TestClub):
"""Test that root users can add members to clubs"""
self.client.force_login(self.root)
response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 3}
self.new_members_url,
{"user": self.subscriber.id, "role": self.board_role.id},
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
self.assert_membership_started_today(self.subscriber, role=self.board_role)
def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed
@@ -271,7 +266,7 @@ class TestMembership(TestClub):
"""
for user in self.public, self.old_subscriber:
form = ClubAddMemberForm(
data={"user": user.id, "role": 1},
data={"user": user.id, "role": self.member_role},
request_user=self.root,
club=self.club,
)
@@ -292,7 +287,7 @@ class TestMembership(TestClub):
nb_memberships = self.simple_board_member.memberships.count()
self.client.post(
self.members_url,
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
{"users": self.simple_board_member.id, "role": self.member_role},
)
self.simple_board_member.refresh_from_db()
assert nb_memberships == self.simple_board_member.memberships.count()
@@ -311,7 +306,7 @@ class TestMembership(TestClub):
max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm(
data={"user": members, "role": 1},
data={"user": members, "role": self.member_role},
request_user=self.root,
club=self.club,
)
@@ -327,12 +322,13 @@ class TestMembership(TestClub):
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=10).user
president = self.club.members.get(role=self.president_role).user
nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president)
response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 9}
self.new_members_url,
{"user": self.subscriber.id, "role": self.president_role.id},
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
@@ -342,14 +338,17 @@ class TestMembership(TestClub):
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=9)
self.assert_membership_started_today(self.subscriber, role=self.president_role)
def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
user_role = self.simple_board_member.memberships.first().role
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
other_role.above(user_role)
form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": 10},
data={"user": self.subscriber.id, "role": other_role.id},
request_user=self.simple_board_member,
club=self.club,
)
@@ -357,7 +356,10 @@ class TestMembership(TestClub):
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
@@ -373,8 +375,9 @@ class TestMembership(TestClub):
assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self):
role = ClubRole.objects.get(members__user=self.simple_board_member)
form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": 3},
data={"user": self.simple_board_member, "role": role.id},
request_user=self.root,
club=self.club,
)
@@ -385,22 +388,27 @@ class TestMembership(TestClub):
def test_add_other_member_forbidden(self):
non_member = subscriber_user.make()
simple_member = baker.make(Membership, club=self.club, role=1).user
simple_member = baker.make(
Membership, club=self.club, role=self.member_role
).user
for user in non_member, simple_member:
form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": 1},
data={"user": subscriber_user.make(), "role": self.member_role.id},
request_user=user,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
}
def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members"""
user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=1)
baker.make(Membership, club=self.club, user=user, role=self.member_role)
self.client.force_login(user)
res = self.client.get(self.members_url)
assert res.status_code == 200
@@ -419,9 +427,10 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships
of users with lower roles.
"""
# reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member)
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
role = baker.make(ClubRole, club=self.club, is_board=True)
role.below(self.board_role)
membership = baker.make(Membership, club=self.club, role=role)
response = self.client.post(self.members_url, {"members_old": [membership.id]})
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
@@ -431,7 +440,9 @@ class TestMembership(TestClub):
"""Test that board members of the club cannot end memberships
of users with higher roles.
"""
membership = self.president.memberships.filter(club=self.club).first()
membership = self.president.memberships.filter(
club=self.club, end_date=None
).first()
self.client.force_login(self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]})
self.club.refresh_from_db()
@@ -473,7 +484,9 @@ class TestMembership(TestClub):
def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User)
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
baker.make(
Membership, user=user, club=self.club, end_date=None, role=self.board_role
)
assert user.groups.contains(self.club.members_group)
assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate())
@@ -484,18 +497,43 @@ class TestMembership(TestClub):
"""Test that when a membership begins, the user is added to the club group."""
assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
def test_add_to_club_role_group(self):
groups = baker.make(Group, _quantity=4)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:3])
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert set(self.subscriber.groups.all()) == {
*groups[:3],
self.club.board_group,
self.club.members_group,
}
def test_remove_from_club_role_group(self):
groups = baker.make(Group, _quantity=3)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:])
self.subscriber.memberships.update(end_date=localdate())
assert set(self.subscriber.groups.all()) == {groups[0]}
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=3
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
membership.role = 1
membership.role = self.member_role
membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
@@ -508,7 +546,11 @@ class TestMembership(TestClub):
# make sli a board member
self.sli.memberships.all().delete()
Membership(club=self.ae, user=self.sli, role=3).save()
Membership(
club=self.ae,
user=self.sli,
role=baker.make(ClubRole, club=self.ae, is_board=True),
).save()
assert self.club.is_owned_by(self.sli)
def test_change_club_name(self):
@@ -532,6 +574,79 @@ class TestMembership(TestClub):
assert new_board == initial_board
@pytest.mark.django_db
def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
client.force_login(membership.user)
response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
membership.refresh_from_db()
assert membership.end_date == localdate()
@pytest.mark.django_db
def test_membership_delete(client: Client):
user = baker.make(User, is_superuser=True)
membership = baker.make(Membership)
client.force_login(user)
url = reverse("club:membership_delete", kwargs={"membership_id": membership.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(url)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db
class TestAddMemberForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.roles = baker.make(
ClubRole,
club=cls.club,
is_board=iter([True, True, True, True, False, False]),
is_presidency=iter([True, True, False, False, False, False]),
order=seq(0),
_quantity=6,
_bulk_create=True,
)
cls.roles[-1].is_active = False
cls.roles[-1].save()
def test_admin(self):
"""Test that admin users can give any active role."""
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="add_membership")]
)
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_president(self):
"""Test that someone with a presidency role can give any active role."""
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_board_member(self):
"""Test that someone with a board role can give lower active role."""
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[3:-1]
def test_simple_member(self):
"""Test that someone with a non-board role cannot give roles."""
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == []
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)
@@ -539,55 +654,64 @@ class TestJoinClub:
cache.clear()
@pytest.mark.parametrize(
("user_factory", "role", "errors"),
("user_factory", "board_role", "errors"),
[
(
subscriber_user.make,
2,
True,
{
"role": [
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
},
),
(
lambda: baker.make(User),
1,
False,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
),
],
)
def test_join_club_errors(
self, user_factory: Callable[[], User], role: int, errors: dict
self, user_factory: Callable[[], User], board_role, errors: dict
):
club = baker.make(Club)
user = user_factory()
form = JoinClubForm(club=club, request_user=user, data={"role": role})
role = baker.make(ClubRole, club=club, is_board=board_role)
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
assert not form.is_valid()
assert form.errors == errors
def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club)
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
role = baker.make(ClubRole, is_board=False)
baker.make(Membership, user=user, club=role.club)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make()
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
role = baker.make(ClubRole, is_board=False)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert form.is_valid()
form.save()
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
class TestOldMembersView(TestCase):
@classmethod
def setUpTestData(cls):
club = baker.make(Club)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
roles = baker.make(
ClubRole,
club=club,
is_board=itertools.cycle([True, True, False]),
order=seq(0),
_quantity=10,
_bulk_create=True,
)
cls.memberships = baker.make(
Membership,
role=iter(roles),
@@ -612,3 +736,11 @@ class TestOldMembersView(TestCase):
self.client.force_login(baker.make(User))
res = self.client.get(self.url)
assert res.status_code == 403
def test_context_data(self):
# mark a membership as not ended, to make sure it is excluded from the result
self.memberships[0].end_date = None
self.memberships[0].save()
self.client.force_login(subscriber_user.make())
res = self.client.get(self.url)
assert list(res.context_data.get("old_members")) == self.memberships[1:]
+49 -8
View File
@@ -1,14 +1,19 @@
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club
from core.markdown import markdown
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client):
@@ -16,12 +21,11 @@ def test_page_display_on_club_main_page(client: Client):
club = baker.make(Club)
content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=1, content=content)
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail").find(class_="markdown")
detail_html = soup.find(id="club-page").find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
@@ -30,10 +34,47 @@ def test_club_main_page_without_content(client: Client):
"""Test the club view works, even if the club page is empty"""
club = baker.make(Club)
club.page.revisions.all().delete()
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail")
detail_html = soup.find(id="club-page")
assert detail_html.find_all("markdown") == []
@pytest.mark.django_db
def test_page_revision(client: Client):
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
client.force_login(baker.make(User))
url = reverse(
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
)
res = client.get(url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
@pytest.mark.django_db
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(
Membership,
user=user,
club=club,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet"
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": content})
assertRedirects(res, reverse("club:club_view", kwargs={"club_id": club.id}))
assert club.page.revisions.last().content == content
+5 -1
View File
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
@@ -7,6 +8,9 @@ from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
+73 -4
View File
@@ -1,5 +1,8 @@
import csv
import itertools
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
@@ -7,16 +10,23 @@ from club.forms import SellingsForm
from club.models import Club
from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer
from counter.models import Counter, Customer, Product, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client):
"""Basic crashtest on club sales view."""
club = baker.make(Club)
product = baker.make(Product, club=club)
admin = baker.make(User, is_superuser=True)
client.force_login(admin)
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
assert response.status_code == 200
url = reverse("club:club_sellings", kwargs={"club_id": club.id})
assert client.get(url).status_code == 200
assert client.post(url).status_code == 200
assert client.post(url, data={"products": [product.id]}).status_code == 200
@pytest.mark.django_db
@@ -36,3 +46,62 @@ def test_sales_form_counter_filter():
form = SellingsForm(club)
form_counters = list(form.fields["counters"].queryset)
assert form_counters == [counters[1], counters[2], counters[0]]
@pytest.mark.django_db
def test_club_sales_csv(client: Client):
client.force_login(baker.make(User, is_superuser=True))
club = baker.make(Club)
counter = baker.make(Counter, club=club)
product = product_recipe.make(club=club, counters=[counter], purchase_price=0.5)
customers = baker.make(Customer, amount=100, _quantity=2, _bulk_create=True)
sales: list[Selling] = sale_recipe.make(
club=club,
counter=counter,
quantity=2,
unit_price=1.5,
product=iter([product, product, None]),
customer=itertools.cycle(customers),
_quantity=3,
)
url = reverse("club:sellings_csv", kwargs={"club_id": club.id})
response = client.post(url, data={"counters": [counter.id]})
assert response.status_code == 200
reader = csv.reader(s.decode() for s in response.streaming_content)
data = list(reader)
sale_rows = [
[
str(s.date),
str(counter),
str(s.seller),
s.customer.user.get_display_name(),
s.label,
"2",
"1.50",
"3.00",
"Compte utilisateur",
]
for s in sales[::-1]
]
sale_rows[2].extend(["0.50", "1.00"])
sale_rows[1].extend(["0.50", "1.00"])
sale_rows[0].extend(["", ""])
assert data == [
["Quantité", "6"],
["Total", "9"],
["Bénéfice", "1"],
[
"Date",
"Comptoir",
"Barman",
"Client",
"Étiquette",
"Quantité",
"Prix unitaire",
"Total",
"Méthode de paiement",
"Prix d'achat",
"Bénéfice",
],
*sale_rows,
]
+53
View File
@@ -0,0 +1,53 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership
from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
class TestFetchClub(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership,
user=cls.user,
start_date=localdate() - timedelta(days=2),
role=baker.make(ClubRole),
)
cls.members = Membership.objects.bulk_create(
[
recipe.prepare(club=clubs[0]),
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
recipe.prepare(club=clubs[1]),
]
)
def test_fetch_memberships(self):
self.client.force_login(subscriber_user.make())
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
]
def test_fetch_club_nb_queries(self):
self.client.force_login(subscriber_user.make())
with self.assertNumQueries(6):
# - 5 queries for authentication
# - 1 query for the actual data
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200
+20
View File
@@ -35,6 +35,10 @@ from club.views import (
ClubPageEditView,
ClubPageHistView,
ClubRevView,
ClubRoleBoardCreateView,
ClubRoleMemberCreateView,
ClubRolePresidencyCreateView,
ClubRoleUpdateView,
ClubSellingCSVView,
ClubSellingView,
ClubToolsView,
@@ -71,6 +75,22 @@ urlpatterns = [
ClubOldMembersView.as_view(),
name="club_old_members",
),
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
path(
"<int:club_id>/role/new/president/",
ClubRolePresidencyCreateView.as_view(),
name="new_role_president",
),
path(
"<int:club_id>/role/new/board/",
ClubRoleBoardCreateView.as_view(),
name="new_role_board",
),
path(
"<int:club_id>/role/new/member/",
ClubRoleMemberCreateView.as_view(),
name="new_role_member",
),
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
path(
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
+228 -85
View File
@@ -22,37 +22,55 @@
#
#
from __future__ import annotations
import csv
import itertools
from typing import Any
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum
from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.db.models import F, Prefetch, Q, Sum
from django.db.models.functions import Length
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.safestring import SafeString
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, FormMixin, UpdateView
from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
ClubRoleCreateForm,
ClubRoleFormSet,
ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
)
from club.models import Club, Mailing, MailingSubscription, Membership
from club.models import (
Club,
ClubLink,
ClubRole,
LinkType,
Mailing,
MailingSubscription,
Membership,
)
from com.models import Poster
from com.views import (
PosterCreateBaseView,
@@ -61,11 +79,19 @@ from com.views import (
PosterListBaseView,
)
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import (
AllowFragment,
FragmentMixin,
FragmentRenderer,
TabedViewMixin,
)
from counter.models import Selling
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self):
@@ -75,6 +101,8 @@ class ClubTabsMixin(TabedViewMixin):
self.object = self.object.page.club
elif isinstance(self.object, Poster):
self.object = self.object.club
elif hasattr(self, "club"):
self.object = self.club
return self.object.get_display_name()
def get_list_of_tabs(self):
@@ -172,15 +200,43 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list
class ClubListView(ListView):
"""List the Clubs."""
class ClubListView(AllowFragment, FormMixin, ListView):
"""List the clubs of the AE, with a form to perform basic search.
Notes:
This view is fully public, because we want to advertise as much as possible
the cultural life of the AE.
In accordance with that matter, searching and listing the clubs is done
entirely server-side (no AlpineJS involved) ;
this is done this way in order to be sure the page is the most accessible
and SEO-friendly possible, even if it makes the UX slightly less smooth.
"""
model = Club
template_name = "club/club_list.jinja"
queryset = (
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
)
context_object_name = "club_list"
form_class = ClubSearchForm
queryset = Club.objects.prefetch_related(
Prefetch("links", queryset=ClubLink.objects.select_related("link_type"))
).order_by("name")
paginate_by = 20
def get_form_kwargs(self):
res = super().get_form_kwargs()
# if request.GET is empty, the form will interpret club_status as None,
# even though we want it to be initially True,
# so we force a defaut True value.
res["data"] = {"club_status": True} | self.request.GET.dict()
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
qs = qs.filter(name__icontains=name)
if (is_active := form.cleaned_data.get("club_status")) is not None:
qs = qs.filter(is_active=is_active)
return qs
class ClubView(ClubTabsMixin, DetailView):
@@ -199,10 +255,11 @@ class ClubView(ClubTabsMixin, DetailView):
.values_list("content", flat=True)
.first()
)
kwargs["links"] = list(self.object.links.select_related("link_type").all())
return kwargs
class ClubRevView(ClubView):
class ClubRevView(LoginRequiredMixin, ClubView):
"""Display a specific page revision."""
def dispatch(self, request, *args, **kwargs):
@@ -216,26 +273,26 @@ class ClubRevView(ClubView):
return kwargs
class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
class ClubPageEditView(ClubTabsMixin, BasePageEditView):
template_name = "club/pagerev_edit.jinja"
current_tab = "page_edit"
def dispatch(self, request, *args, **kwargs):
self.club = get_object_or_404(Club, pk=kwargs["club_id"])
if not self.club.page:
raise Http404
return super().dispatch(request, *args, **kwargs)
@cached_property
def club(self):
return get_object_or_404(Club, pk=self.kwargs["club_id"])
def get_object(self):
self.page = self.club.page
return self._get_revision()
@cached_property
def page(self) -> Page:
page = self.club.page
page.set_lock(self.request.user)
return page
def get_success_url(self, **kwargs):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Modification hostory of the page."""
"""Modification history of the page."""
model = Club
pk_url_kwarg = "club_id"
@@ -310,7 +367,7 @@ class ClubMembersView(
membership = self.object.get_membership_for(self.request.user)
if (
membership
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
and not membership.role.is_board
and not self.request.user.has_perm("club.add_membership")
):
# Simple club members won't see the form anymore.
@@ -335,8 +392,8 @@ class ClubMembersView(
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("-role")
.select_related("user")
.order_by("role__order")
.select_related("user", "role")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs
@@ -364,12 +421,125 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date")
.select_related("user")
.order_by("role__order", "description", "-end_date")
.select_related("user", "role")
)
}
class ClubRoleUpdateView(
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
form_class = ClubRoleFormSet
model = Club
template_name = "club/club_roles.jinja"
pk_url_kwarg = "club_id"
current_tab = "members"
success_message = _("Club roles updated")
@cached_property
def club(self) -> Club:
return self.get_object()
def test_func(self):
return self.club.can_roles_be_edited_by(self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
def get_success_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"user_role": ClubRole.objects.filter(
club=self.club,
members__user=self.request.user,
members__end_date=None,
)
.values_list("id", flat=True)
.first()
}
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
This view isn't meant to be called directly, but rather subclassed for each
type of role that can exist :
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
to create a presidency role
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
to create a board role
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
to create a member role
Each subclass have to override the following variables :
- `is_presidency` and `is_board`, indicating what type of role
the view creates.
- `role_description`, which is the title of the page, indication
the user what kind of role is being created.
This way, we are making sure the correct type of role will
be created, without bothering the user with the implementation details.
"""
form_class = ClubRoleCreateForm
model = ClubRole
template_name = "core/create.jinja"
success_message = _("Role %(name)s created")
role_description = ""
is_presidency: bool
is_board: bool
@cached_property
def club(self):
return get_object_or_404(Club, id=self.kwargs["club_id"])
def test_func(self):
return self.request.user.is_authenticated and (
self.request.user.has_perm("club.add_clubrole")
or self.club.members.filter(
user=self.request.user, role__is_presidency=True
).exists()
)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"club": self.club,
"is_presidency": self.is_presidency,
"is_board": self.is_board,
}
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": self.role_description
}
def get_success_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
is_presidency = True
is_board = True
role_description = _("club role \u2013 presidency")
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = True
role_description = _("club role \u2013 board")
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = False
role_description = _("club role \u2013 member")
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""Sales of a club."""
@@ -399,33 +569,14 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
kwargs = super().get_context_data(**kwargs)
kwargs["result"] = Selling.objects.none()
kwargs["paginated_result"] = kwargs["result"]
kwargs["total"] = 0
kwargs["total_quantity"] = 0
kwargs["benefit"] = 0
form = self.get_form()
if form.is_valid():
qs = Selling.objects.filter(club=self.object)
if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none()
if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]:
qs = qs.filter(date__lte=form.cleaned_data["end_date"])
if form.cleaned_data["counters"]:
qs = qs.filter(counter__in=form.cleaned_data["counters"])
selected_products = []
if form.cleaned_data["products"]:
selected_products.extend(form.cleaned_data["products"])
if form.cleaned_data["archived_products"]:
selected_products.extend(form.cleaned_data["archived_products"])
if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products)
form: SellingsForm = self.get_form()
if form.is_valid() and any(v for v in form.cleaned_data.values()):
filters = form.to_filter_schema()
qs = filters.filter(Selling.objects.filter(club=self.object))
kwargs["total"] = qs.annotate(
price=F("quantity") * F("unit_price")
).aggregate(total=Sum("price", default=0))["total"]
@@ -472,15 +623,15 @@ class ClubSellingCSVView(ClubSellingView):
*row,
selling.label,
selling.quantity,
selling.unit_price,
selling.quantity * selling.unit_price,
selling.get_payment_method_display(),
]
if selling.product:
row.append(selling.product.selling_price)
row.append(selling.product.purchase_price)
row.append(selling.product.selling_price - selling.product.purchase_price)
row.append(selling.unit_price - selling.product.purchase_price)
else:
row = [*row, "", "", ""]
row = [*row, "", ""]
return row
def get(self, request, *args, **kwargs):
@@ -501,9 +652,9 @@ class ClubSellingCSVView(ClubSellingView):
gettext("Customer"),
gettext("Label"),
gettext("Quantity"),
gettext("Unit price"),
gettext("Total"),
gettext("Payment method"),
gettext("Selling price"),
gettext("Purchase price"),
gettext("Benefit"),
],
@@ -545,6 +696,11 @@ class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView):
return ClubAdminEditForm
return ClubEditForm
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"link_types": list(LinkType.objects.order_by(Length("url_base").desc()))
}
class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin)."""
@@ -555,34 +711,23 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
template_name = "core/create.jinja"
permission_required = "club.add_club"
def form_valid(self, form):
res = super().form_valid(form)
self.object.create_default_roles()
return res
class MembershipSetOldView(CanEditMixin, DetailView):
"""Set a membership as beeing old."""
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as being old."""
model = Membership
pk_url_kwarg = "membership_id"
def get(self, request, *args, **kwargs):
def post(self, *_args, **_kwargs):
self.object = self.get_object()
self.object.end_date = timezone.now()
self.object.save()
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
return redirect("core:user_clubs", user_id=self.object.user_id)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
@@ -594,7 +739,7 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "club.delete_membership"
def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id})
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
@@ -751,9 +896,7 @@ class MailingAutoGenerationView(View):
def get(self, request, *args, **kwargs):
club = self.mailing.club
self.mailing.subscriptions.all().delete()
members = club.members.filter(
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
).exclude(end_date__lte=timezone.now())
members = club.members.ongoing().filter(role__is_board=True)
for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id)
+4 -1
View File
@@ -1,4 +1,5 @@
from datetime import date
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta
from django import forms
@@ -9,11 +10,13 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from core.models import User
class PosterForm(forms.ModelForm):
class Meta:
+35 -18
View File
@@ -1,19 +1,25 @@
from pathlib import Path
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain
from django.db.models import F, QuerySet
from django.http import HttpRequest
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.event import Event
from ical.types import Frequency, Recur
from com.models import NewsDate
from core.models import User
from com.models import News, NewsDate
if TYPE_CHECKING:
from pathlib import Path
from django.http import HttpRequest
from core.models import User
def as_absolute_url(url: str, request: HttpRequest | None = None) -> str:
@@ -42,9 +48,9 @@ class IcsCalendar:
with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(
cls.ics_from_queryset(
NewsDate.objects.filter(
news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
News.objects.filter(
is_published=True,
dates__end_date__gte=timezone.now() - relativedelta(months=6),
)
)
)
@@ -53,24 +59,35 @@ class IcsCalendar:
@classmethod
def get_unpublished(cls, user: User) -> bytes:
return cls.ics_from_queryset(
NewsDate.objects.viewable_by(user).filter(
news__is_published=False,
end_date__gte=timezone.now() - (relativedelta(months=6)),
),
News.objects.viewable_by(user).filter(
is_published=False,
dates__end_date__gte=timezone.now() - relativedelta(months=6),
)
)
@classmethod
def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
def ics_from_queryset(cls, queryset: QuerySet[News]) -> bytes:
calendar = Calendar()
for news_date in queryset.annotate(news_title=F("news__title")):
date_subquery = NewsDate.objects.filter(news=OuterRef("pk")).order_by(
"start_date"
)
queryset = queryset.annotate(
start=Subquery(date_subquery.values("start_date")[:1]),
end=Subquery(date_subquery.values("end_date")[:1]),
nb_dates=Count("dates"),
)
for news in queryset:
event = Event(
summary=news_date.news_title,
start=news_date.start_date,
end=news_date.end_date,
summary=news.title,
description=news.summary,
dtstart=news.start,
dtend=news.end,
url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
reverse("com:news_detail", kwargs={"news_id": news.id})
),
)
if news.nb_dates > 1:
event.rrule = Recur(freq=Frequency.WEEKLY, count=news.nb_dates)
calendar.events.append(event)
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")
+8 -8
View File
@@ -1,22 +1,22 @@
from datetime import datetime
from typing import Annotated
from ninja import FilterSchema, ModelSchema
from aemark import markdown
from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.context import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema
from com.models import News, NewsDate
from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt")
after: datetime | None = Field(None, q="start_date__gt")
club_id: int | None = Field(None, q="news__club_id")
before: Annotated[datetime | None, FilterLookup("end_date__lt")] = None
after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None
club_id: Annotated[int | None, FilterLookup("news__club_id")] = None
news_id: int | None = None
is_published: bool | None = Field(None, q="news__is_published")
title: str | None = Field(None, q="news__title__icontains")
is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None
title: Annotated[str | None, FilterLookup("news__title__icontains")] = None
class NewsSchema(ModelSchema):
@@ -1,6 +1,9 @@
import { makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { Calendar, type EventClickArg } from "@fullcalendar/core";
import {
Calendar,
type EventClickArg,
type EventContentArg,
type EventInput,
} from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
@@ -8,6 +11,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { type GenericEndpoint, makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -19,21 +24,28 @@ import {
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
private helpUrl = "";
private calendar!: Calendar;
private locale? = "en";
private canModerate? = false;
private canDelete? = false;
private helpUrl? = "";
// Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules
// That's why we have to detect those events ourselves
private recurrenceMap: Map<string, EventImpl> = new Map();
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") {
this.locale = newValue;
}
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
this.canModerate =
typeof newValue === "string" && newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
this.canDelete =
typeof newValue === "string" && newValue.toLowerCase() === "true";
}
if (name === "ics-help-url") {
@@ -89,12 +101,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
.pop() as string,
10,
);
}
refreshEvents() {
this.click(); // Remove focus from popup
this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event
this.calendar.refetchEvents();
}
@@ -152,20 +166,33 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents();
}
async getEventSources() {
async getEventSources(): Promise<EventInput[]> {
const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url
// We rely on the fact that the event url is always the same
// for recurring events and always different for single events
const firstEvent = this.recurrenceMap.get(eventData.url);
if (firstEvent !== undefined) {
eventData.extendedProps.isRecurring = true;
firstEvent.extendedProps.isRecurring = true; // Don't forget the first event
}
this.recurrenceMap.set(eventData.url, eventData);
};
return [
{
url: `${await makeUrl(calendarCalendarInternal)}`,
url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
format: "ics",
className: "internal",
cache: false,
eventDataTransform: tagRecurringEvents,
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}`,
url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
format: "ics",
color: "red",
className: "unpublished",
cache: false,
eventDataTransform: tagRecurringEvents,
},
];
}
@@ -193,7 +220,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title}
</h4>
<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>
</div>
`;
@@ -231,7 +258,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")}
@@ -318,9 +345,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied");
}
button.setAttribute("tooltip", gettext("Link copied"));
navigator.clipboard.writeText(
await navigator.clipboard.writeText(
new URL(
await makeUrl(calendarCalendarInternal),
await makeUrl(calendarCalendarInternal as GenericEndpoint),
window.location.origin,
).toString(),
);
@@ -361,6 +388,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
event.jsEvent.preventDefault();
this.createEventDetailPopup(event);
},
eventClassNames: (classNamesEvent: EventContentArg) => {
const classes: string[] = [];
if (classNamesEvent.event.extendedProps?.isRecurring) {
classes.push("recurring");
}
return classes;
},
});
this.calendar.render();
@@ -1,5 +1,9 @@
import { exportToHtml } from "#core:utils/globals";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
import {
newsDeleteNews,
newsFetchNewsDates,
newsPublishNews,
type PaginatedResponseSchemaNewsDateSchema,
} from "#openapi";
// This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript
@@ -13,7 +17,8 @@ const AlertState = {
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DISPLAYED: 4, // When published at page generation
};
exportToHtml("AlertState", AlertState);
// biome-ignore lint/style/useNamingConvention: it's an enum, PascalCase is better
Object.assign(window, { AlertState });
document.addEventListener("alpine:init", () => {
Alpine.data("moderationAlert", (newsId: number) => ({
@@ -64,7 +69,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return response.data.count;
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
},
weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
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
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
}
};
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,6 +32,11 @@ document.addEventListener("alpine:init", () => {
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) {
this.hasNext = false;
} else if (response.data.next === null) {
@@ -44,7 +49,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false;
},
groupedDates(): Record<string, NewsDateSchema[]> {
groupedDates(): Record<string, ParsedNewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
+28 -1
View File
@@ -18,6 +18,8 @@
--event-details-border-radius: 4px;
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px;
--event-recurring-internal-color: #6f69cd;
--event-recurring-unpublished-color: orange;
}
ics-calendar {
@@ -146,4 +148,29 @@ ics-calendar {
.tooltip.calendar-copy-tooltip.text-copied {
opacity: 0;
transition: opacity 500ms ease-out;
}
}
// We have to override the color set by the lib in the html
// Hence the !important tag everywhere
.internal.recurring {
.fc-daygrid-event-dot {
border-color: var(--event-recurring-internal-color) !important;
}
&.fc-daygrid-block-event {
background-color: var(--event-recurring-internal-color) !important;
border-color: var(--event-recurring-internal-color) !important;
}
}
.unpublished.recurring {
.fc-daygrid-event-dot {
border-color: var(--event-recurring-unpublished-color) !important;
}
&.fc-daygrid-block-event {
background-color: var(--event-recurring-unpublished-color) !important;
border-color: var(--event-recurring-unpublished-color) !important;
}
}
+27 -31
View File
@@ -3,6 +3,7 @@
#news {
display: flex;
gap: 1em;
@media (max-width: 800px) {
flex-direction: column;
@@ -15,9 +16,13 @@
#right_column {
flex: 20%;
margin: 3.2px;
display: inline-block;
vertical-align: top;
@media screen and (min-width: 800px) {
max-width: 20%;
min-width: 200px;
}
}
#left_column {
@@ -26,12 +31,14 @@
}
h3 {
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
background: lighten($second-color, 5%);
box-shadow: var(--box-shadow);
padding: .75rem;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 17px;
border-radius: 10px;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
@@ -39,12 +46,11 @@
.feed {
float: right;
color: #f26522;
color: #e25512;
}
}
@media screen and (max-width: $small-devices) {
@media screen and (max-width: 800px) {
#left_column,
#right_column {
flex: 100%;
@@ -57,6 +63,7 @@
max-height: 600px;
overflow-y: scroll;
overflow-x: clip;
margin-top: 1em;
#load-more-news-button {
text-align: center;
@@ -73,18 +80,14 @@
display: block;
width: 100%;
background: white;
font-size: 70%;
margin-bottom: 1em;
h3 {
margin-bottom: 0;
}
font-size: 85%;
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding-bottom: 1em;
padding: 1em;
h4 {
margin-left: 5px;
@@ -97,23 +100,10 @@
li {
margin: 10px;
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
i {
width: 25px;
text-align: center;
margin-right: .5rem;
}
}
}
@@ -121,12 +111,15 @@
}
#birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
margin: 0;
list-style-type: none;
font-weight: bold;
>li {
> li {
padding: 0.5em;
&:nth-child(even) {
@@ -135,8 +128,7 @@
}
ul {
margin: 0;
margin-left: 1em;
margin: .5em 0 0 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
@@ -150,9 +142,13 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
@media screen and (max-width: $small-devices) {
margin-left: 3px;
}
.news_events_group_date {
display: table-cell;
padding: 0.6em;
+12 -6
View File
@@ -39,12 +39,18 @@
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
</div>
<h4>{{ news.title }}</h4>
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p>
{% if date %}
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</p>
{% endif %}
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
+3 -3
View File
@@ -23,7 +23,7 @@
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
@@ -203,7 +203,7 @@
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UE Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-calendar-days fa-xl"></i>
@@ -211,7 +211,7 @@
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
+1 -1
View File
@@ -4,7 +4,7 @@
<title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/base-bundle-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
</head>
<body x-data="slideshow([
+4 -12
View File
@@ -1,11 +1,11 @@
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from django.conf import settings
from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
@@ -14,18 +14,10 @@ from pytest_django.asserts import assertNumQueries
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User
@dataclass
class MockResponse:
ok: bool
value: str
@property
def content(self):
return self.value.encode("utf8")
if TYPE_CHECKING:
from django.http import HttpResponse
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
+2 -1
View File
@@ -7,7 +7,7 @@ from model_bakery import baker
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User
from core.models import Group, Notification, SithFile, User
@pytest.mark.django_db
@@ -18,6 +18,7 @@ def test_notification_created():
past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
com_admin_group.users.all().delete()
Notification.objects.all().delete()
com_admin = baker.make(User, groups=[com_admin_group])
+38 -2
View File
@@ -26,9 +26,10 @@ from django.utils import html
from django.utils.timezone import now
from django.utils.translation import gettext as _
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
@@ -214,7 +215,8 @@ class TestNewsCreation(TestCase):
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.user = subscriber_user.make()
baker.make(Membership, user=cls.user, club=cls.club, role=5)
role = baker.make(ClubRole, club=cls.club, is_board=True)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
def setUp(self):
self.client.force_login(self.user)
@@ -361,3 +363,37 @@ def test_moderate_poster(client: Client, referer: str | None):
poster.refresh_from_db()
assert poster.is_moderated
assert poster.moderator == user
class TestNewsDetail(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.news = baker.make(News, is_published=True)
cls.user = subscriber_user.make()
cls.url = reverse("com:news_detail", kwargs={"news_id": cls.news.id})
def test_page_ok(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
def test_displayed_date(self):
self.client.force_login(self.user)
n = now()
date_recipe = Recipe(NewsDate, news=self.news)
date_recipe.make(
start_date=n - timedelta(days=1, hours=6), end_date=n - timedelta(days=1)
)
date_recipe.make(
start_date=n - timedelta(hours=6), end_date=n + timedelta(hours=1)
)
expected = date_recipe.make( # we expect the next happening date.
start_date=n + timedelta(days=1), end_date=n + timedelta(days=1, hours=1)
)
date_recipe.make(
start_date=n + timedelta(days=2), end_date=n + timedelta(days=2, hours=1)
)
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
assert res.context_data["date"] == expected
+8 -6
View File
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
form_class = NewsForm
template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id"
permission_required = "com.edit_news"
permission_required = "com.change_news"
def form_valid(self, form):
response = super().form_valid(form) # Does the saving part
@@ -240,12 +240,12 @@ class NewsListView(TemplateView):
if not self.request.user.has_perm("core.view_user"):
return []
return itertools.groupby(
User.objects.filter(
User.objects.viewable_by(self.request.user)
.filter(
date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day,
is_subscriber_viewable=True,
role__in=["STUDENT", "FORMER STUDENT"],
)
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year,
)
@@ -301,7 +301,9 @@ class NewsDetailView(CanViewMixin, DetailView):
queryset = News.objects.select_related("club", "author", "moderator")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
return super().get_context_data(**kwargs) | {
"date": self.object.dates.filter(start_date__gt=now()).first()
}
class NewsFeed(Feed):
@@ -503,7 +505,7 @@ class WeekmailArticleCreateView(CreateView):
self.object = form.instance
form.is_valid() # Valid a first time to populate club field
m = form.instance.club.get_membership_for(request.user)
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
if m is None or not m.role.is_board:
form.add_error(
"club",
ValidationError(
+11
View File
@@ -63,6 +63,7 @@ class UserAdmin(admin.ModelAdmin):
"scrub_pict",
"user_permissions",
"groups",
"whitelisted_users",
)
inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"]
@@ -74,9 +75,19 @@ class UserBanAdmin(admin.ModelAdmin):
autocomplete_fields = ("user", "ban_group")
class GroupInline(admin.TabularInline):
model = Group.permissions.through
readonly_fields = ("group",)
extra = 0
def has_add_permission(self, request, obj):
return False
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page)
+20 -24
View File
@@ -1,9 +1,8 @@
from typing import Annotated, Any, Literal
from typing import Annotated, Literal
import annotated_types
from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query
from ninja.security import SessionAuth
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 (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
UploadedImage,
@@ -26,16 +24,9 @@ from core.schemas import (
UserFilterSchema,
UserProfileSchema,
UserSchema,
ValidationErrorSchema,
)
from core.templatetags.renderer import markdown
@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")
from counter.utils import is_logged_in_counter
@api_controller("/upload")
@@ -44,7 +35,7 @@ class UploadController(ControllerBase):
"/image",
response={
200: UploadedFileSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
422: ValidationErrorSchema,
403: dict[Literal["detail"], str],
},
permissions=[HasPerm("core.add_quickuploadimage")],
@@ -72,9 +63,9 @@ class MailingListController(ControllerBase):
@api_controller("/user")
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
@route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)
return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
@@ -85,13 +76,18 @@ class UserController(ControllerBase):
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
permissions=[CanAccessLookup],
# logged in barmen aren't authenticated stricto sensu, so no auth here
auth=None,
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(
User.objects.order_by(F("last_login").desc(nulls_last=True))
)
qs = User.objects
# the logged in barmen can see all users (even the hidden one),
# because they have a temporary administrative function during
# which they may have to deal with hidden users
if not is_logged_in_counter(self.context.request):
qs = qs.viewable_by(self.context.request.user)
return filters.filter(qs.order_by(F("last_login").desc(nulls_last=True)))
@api_controller("/file")
@@ -103,7 +99,7 @@ class SithFileController(ControllerBase):
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@@ -116,11 +112,11 @@ class GroupController(ControllerBase):
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).order_by("name").values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DepthValue = Annotated[int, Ge(0), Le(10)]
DEFAULT_DEPTH = 4
+55 -4
View File
@@ -1,15 +1,16 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import typing
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from django.db.models import Exists, OuterRef, Q, QuerySet
from core.models import Group
from core.models import Group, User
if TYPE_CHECKING:
from core.models import User
if typing.TYPE_CHECKING:
from django.db.models.base import Model
class SithModelBackend(ModelBackend):
@@ -40,3 +41,53 @@ class SithModelBackend(ModelBackend):
return Permission.objects.filter(
group__group__in=groups.values_list("pk", flat=True)
)
@typing.override
def with_perm(
self,
perm: str | Permission,
is_active: bool | None = True,
include_superusers: bool = False,
obj: Model | None = None,
) -> QuerySet[User]:
"""Return users that have permission "perm".
Contrary to the base django method, superusers aren't included in the
result.
This is because the OR operation to include superusers in the query result
utterly destroy the query performances on postgres
(it makes it like 1000x slower, and I'm not even kidding).
To overcome that, we could use a UNION instead, but then we wouldn't
be able to perform further filter operations on the queryset.
Thus, the `include_superusers` argument is not used at all.
Because of that, it is useless to set `include_superusers`,
as it will be silently ignored.
The only reason it's still there is not to break the interface
of the base class.
"""
if isinstance(perm, str):
try:
app_label, codename = perm.split(".")
except ValueError as e:
raise ValueError(
"Permission name should be in the form "
"app_label.permission_codename."
) from e
permission_q = Q(codename=codename, content_type__app_label=app_label)
elif isinstance(perm, Permission):
permission_q = Q(pk=perm.pk)
else:
raise TypeError(
"The `perm` argument must be a string or a permission instance."
)
user_q = Exists(
Permission.objects.filter(
Q(group__group__users=OuterRef("pk")) | Q(user=OuterRef("pk")),
permission_q,
)
)
if is_active is not None:
user_q &= Q(is_active=is_active)
return User.objects.filter(user_q)
+3 -2
View File
@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
return False
if super().has_permission():
return True
return self.club is not None and any(
g.id == self.club.board_group_id for g in self.request.user.cached_groups
return (
self.club is not None
and self.club.board_group_id in self.request.user.all_groups
)
+5 -3
View File
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.utils.timezone import localdate, now
from model_bakery import seq
from model_bakery.recipe import Recipe, related
from model_bakery.recipe import Recipe, foreign_key, related
from club.models import Membership
from club.models import ClubRole, Membership
from core.models import Group, User
from subscription.models import Subscription
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=foreign_key(
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
)
board_user = Recipe(
+11 -8
View File
@@ -1,19 +1,16 @@
class FourDigitYearConverter:
regex = "[0-9]{4}"
from django.urls.converters import IntConverter, StringConverter
def to_python(self, value):
return int(value)
class FourDigitYearConverter(IntConverter):
regex = "[0-9]{4}"
def to_url(self, value):
return str(value).zfill(4)
class TwoDigitMonthConverter:
class TwoDigitMonthConverter(IntConverter):
regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(2)
@@ -28,3 +25,9 @@ class BooleanStringConverter:
def to_url(self, value):
return str(value)
class ResultConverter(StringConverter):
"""Converter whose regex match either "success" or "failure"."""
regex = "(success|failure)"
+35 -36
View File
@@ -2,12 +2,9 @@
<h1>Markdown-AE Documentation</h1>
<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 />
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.</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 />
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>
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/kivikakk/comrak">comrak</a>.</p>
<h2>Basique</h2>
<ul>
<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><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>Mettre du texte^en exposant^ : <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<sup>en exposant</sup> : <code>&lt;sup&gt;texte&lt;/sup&gt;</code></li>
<li>Mettre du texte<sub>en indice</sub> : <code>&lt;sub&gt;texte&lt;/sub&gt;</code></li>
</ul>
<h2>Liens</h2>
<ul>
@@ -28,10 +25,10 @@ En pratique, cette page devrait déjà résumer une bonne partie.</p>
</ul>
<p><a href="http://www.site.com">nom du lien</a></p>
<ul>
<li>Les liens peuvent être internes au site de l'AE, on peut dès lors éviter d'entrer
l'adresse complète d'une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
<li>Les liens peuvent être internes au site de l’AE, on peut dès lors éviter d’entrer
l’adresse complète d’une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
</ul>
<p><a href="/page/nomDeLaPage/">nom du lien</a></p>
<p><a href="/page/nomDeLaPage">nom du lien</a></p>
<ul>
<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>
@@ -94,25 +91,25 @@ etc...
<table>
<thead>
<tr>
<th>Titre</th>
<th>Titre2</th>
<th>Titre3</th>
<th>Titre</th>
<th>Titre2</th>
<th>Titre3</th>
</tr>
</thead>
<tbody>
<tr>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
</tr>
<tr>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
<td>test</td>
</tr>
</tbody>
</table>
<p>L'alignement dans les cellules est géré comme suit, avec les ':' sur la ligne en dessous du titre:</p>
<p>L’alignement dans les cellules est géré comme suit, avec les ‘:’ sur la ligne en dessous du titre:</p>
<pre><code>| Titre | Titre2 | Titre3 |
|:-------|:------:|-------:|
| gauche | centre | droite |
@@ -120,16 +117,16 @@ etc...
<table>
<thead>
<tr>
<th style="text-align:left">Titre</th>
<th style="text-align:center">Titre2</th>
<th style="text-align:right">Titre3</th>
<th align="left">Titre</th>
<th align="center">Titre2</th>
<th align="right">Titre3</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">gauche</td>
<td style="text-align:center">centre</td>
<td style="text-align:right">droite</td>
<td align="left">gauche</td>
<td align="center">centre</td>
<td align="right">droite</td>
</tr>
</tbody>
</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 350x100 pixels](/static/core/img/logo.png?350x100 &quot;Image de 350x100 pixels&quot;)
</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>
<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>
<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>
<p>(devrait pouvoir détecter si vidéo ou non)</p>
<h2>Blocs de citations</h2>
@@ -159,9 +156,9 @@ Image de 350x100 pixels.</p>
un bloc de
citation</p>
</blockquote>
<p>Il est possible d'intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
<p>Il est possible d’intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
<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].
[^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>
<h2>Échapper des caractères</h2>
<ul>
<li>Il est possible d'ignorer un caractère spécial en l'échappant à l'aide d'un \</li>
<li>L'échappement de blocs de codes complet se fera à l'aide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li>
<li>Il est possible d’ignorer un caractère spécial en l’échappant à l’aide d’un \</li>
<li>L’échappement de blocs de codes complet se fera à l’aide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li>
</ul>
<h2>Autres (hérité de l'ancien wiki)</h2>
<h2>Autres (hérité de l’ancien wiki)</h2>
<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>
<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>
</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,
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).
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.
Le réel parseur du site AE est une version tunée de [comrak](https://github.com/kivikakk/comrak).
## Basique
+13 -4
View File
@@ -39,12 +39,16 @@ class Command(BaseCommand):
return None
return xapian.version_string()
def _desired_version(self) -> str:
def _desired_version(self) -> tuple[str, str, str]:
with open(
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
) as f:
pyproject = tomli.load(f)
return pyproject["tool"]["xapian"]["version"]
return (
pyproject["tool"]["xapian"]["version"],
pyproject["tool"]["xapian"]["core-sha256"],
pyproject["tool"]["xapian"]["bindings-sha256"],
)
def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None):
@@ -53,7 +57,7 @@ class Command(BaseCommand):
)
return
desired = self._desired_version()
desired, core_checksum, bindings_checksum = self._desired_version()
if desired == self._current_version():
if not force:
self.stdout.write(
@@ -65,7 +69,12 @@ class Command(BaseCommand):
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
)
subprocess.run(
[str(Path(__file__).parent / "install_xapian.sh"), desired],
[
str(Path(__file__).parent / "install_xapian.sh"),
desired,
core_checksum,
bindings_checksum,
],
env=dict(os.environ),
check=True,
)
+11 -1
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
# first argument of the script is Xapian version (e.g. 1.2.19)
# second argument of the script is core sha256
# second argument of the script is binding sha256
VERSION="$1"
CORE_SHA256="$2"
BINDINGS_SHA256="$3"
# Cleanup env vars for auto discovery mechanism
unset CPATH
@@ -21,9 +25,15 @@ BINDINGS=xapian-bindings-$VERSION
# download
echo "Downloading source..."
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
# extract
echo "Extracting source..."
tar xf "${CORE}.tar.xz"
+1 -2
View File
@@ -22,11 +22,10 @@
#
from aemark import markdown
from django.conf import settings
from django.core.management.base import BaseCommand
from core.markdown import markdown
class Command(BaseCommand):
help = "Output the fully rendered SYNTAX.md file"
+467 -263
View File
@@ -16,11 +16,12 @@
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from datetime import date, timedelta
import itertools
from datetime import date, datetime, timedelta
from io import StringIO
from pathlib import Path
from typing import ClassVar, NamedTuple
@@ -33,18 +34,27 @@ from django.core.management.base import BaseCommand
from django.db import connection
from django.db.models import Q
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 club.models import Club, Membership
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from election.models import Candidature, Election, ElectionList, Role
from counter.models import (
Counter,
CounterSellers,
Price,
Product,
ProductType,
ReturnableProduct,
StudentCard,
)
from election.models import Candidature, Election, ElectionList, Role, Vote
from forum.models import Forum
from pedagogy.models import UV
from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
@@ -62,6 +72,13 @@ class PopulatedGroups(NamedTuple):
campus_admin: Group
class PopulatedClubs(NamedTuple):
ae: Club
troll: Club
pdf: Club
refound: Club
class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = (
@@ -104,35 +121,20 @@ class Command(BaseCommand):
)
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
main_club.board_group.permissions.add(
*Permission.objects.filter(
codename__in=["view_subscription", "add_subscription"]
)
)
bar_club = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
)
clubs = self._create_clubs()
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
self.reset_index("counter")
counters = [
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
Counter(name="AE", club=clubs.ae, type="OFFICE"),
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
]
Counter.objects.bulk_create(counters)
bar_groups = []
@@ -150,7 +152,8 @@ class Command(BaseCommand):
Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
# Here we add a lot of test datas, that are not necessary for the Sith,
# but that provide a basic development environment
self.now = timezone.now().replace(hour=12, second=0)
skia = User.objects.create_user(
@@ -249,6 +252,7 @@ class Command(BaseCommand):
date_of_birth="1942-06-12",
password="plop",
)
User.objects.all().update(cgu_approved_at=now())
User.groups.through.objects.bulk_create(
[
User.groups.through(group=groups.counter_admin, user=counter),
@@ -272,34 +276,7 @@ class Command(BaseCommand):
]
)
# Adding syntax help page
syntax_page = Page(name="Aide_sur_la_syntaxe")
syntax_page.save(force_lock=True)
PageRev.objects.create(
page=syntax_page,
title="Aide sur la syntaxe",
author=skia,
content=(self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md").read_text(),
)
services_page = Page(name="Services")
services_page.save(force_lock=True)
PageRev.objects.create(
page=services_page,
title="Services",
author=skia,
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
)
index_page = Page(name="Index")
index_page.save(force_lock=True)
PageRev.objects.create(
page=index_page,
title="Wiki index",
author=root,
content="Welcome to the wiki page!",
)
groups.public.viewable_page.set([syntax_page, services_page, index_page])
self._create_pages(groups)
self._create_subscription(root)
self._create_subscription(skia)
@@ -314,230 +291,60 @@ class Command(BaseCommand):
self._create_subscription(tutu)
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
# Clubs
Club.objects.create(
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
Membership.objects.create(
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
)
guyut = Club.objects.create(
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
)
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=main_club
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=main_club
)
Membership.objects.create(user=skia, club=main_club, role=3)
Membership.objects.create(
user=comunity,
club=bar_club,
club=clubs.pdf,
start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=clubs.pdf.roles.get(name="Membre du bureau"),
)
Membership.objects.create(
user=sli,
club=troll,
role=9,
club=clubs.troll,
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
)
Membership.objects.create(
user=krophil,
club=troll,
role=10,
club=clubs.troll,
role=clubs.troll.roles.get(name="Président⸱e"),
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
)
Membership.objects.create(
user=skia,
club=troll,
role=2,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
)
Membership.objects.create(
user=richard,
club=troll,
role=2,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
)
p = ProductType.objects.create(name="Bières bouteilles")
c = ProductType.objects.create(name="Cotisations")
r = ProductType.objects.create(name="Rechargements")
verre = ProductType.objects.create(name="Verre")
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=c,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=c,
purchase_price="28",
selling_price="28",
special_selling_price="28",
club=main_club,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=r,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
purchase_price="1",
selling_price="1",
special_selling_price="1",
club=main_club,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
purchase_price="-1",
selling_price="-1",
special_selling_price="-1",
club=main_club,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
groups.old_subscribers.products.add(cotis, cotis2)
self._create_products(groups, clubs)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
# Add barman to counter
Counter.sellers.through.objects.bulk_create(
CounterSellers.objects.bulk_create(
[
Counter.sellers.through(counter_id=2, user=krophil),
Counter.sellers.through(counter=mde, user=skia),
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
]
)
# Create an election
el = Election.objects.create(
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(main_club.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",
),
]
)
self._create_elections(groups, clubs, skia, sli, krophil)
# Forum
room = Forum.objects.create(
@@ -572,7 +379,7 @@ class Command(BaseCommand):
title="Apero barman",
summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -590,7 +397,7 @@ class Command(BaseCommand):
content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
),
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -606,7 +413,7 @@ class Command(BaseCommand):
title="Repas fromager",
summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -622,7 +429,7 @@ class Command(BaseCommand):
title="SdF",
summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -640,7 +447,7 @@ class Command(BaseCommand):
summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!",
club=troll,
club=clubs.troll,
author=subscriber,
is_published=True,
moderator=skia,
@@ -660,20 +467,20 @@ class Command(BaseCommand):
# Create some data for pedagogy
UV(
UE(
code="PA00",
author=User.objects.get(id=0),
credit_type=settings.SITH_PEDAGOGY_UV_TYPE[3][0],
credit_type=settings.SITH_PEDAGOGY_UE_TYPE[3][0],
manager="Laurent HEYBERGER",
semester=settings.SITH_PEDAGOGY_UV_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UV_LANGUAGE[0][0],
semester=settings.SITH_PEDAGOGY_UE_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0],
department=settings.SITH_PROFILE_DEPARTMENTS[-2][0],
credits=5,
title="Participation dans une association étudiante",
objectives="* Permettre aux étudiants de réaliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.",
program="""* Semestre précédent proposition d'un projet et d'un cahier des charges
* Evaluation par un jury de six membres
* Si accord réalisation dans le cadre de l'UV
* Si accord réalisation dans le cadre de l'UE
* Compte-rendu de l'expérience
* Présentation""",
skills="""* Gérer un projet associatif ou une action éducative en autonomie:
@@ -718,8 +525,7 @@ class Command(BaseCommand):
)
pict.file.name = p.name
pict.full_clean()
pict.generate_thumbnails()
pict.save()
pict.generate_thumbnails(save=True)
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -741,6 +547,171 @@ class Command(BaseCommand):
]
)
def _create_pages(self, groups: PopulatedGroups):
pages = Page.objects.bulk_create(
[Page(name=s, _full_name=s) for s in settings.SITH_CGU_PAGE.split("/")]
)
for parent, son in itertools.pairwise(pages):
son.parent = parent
son.save(force_lock=True)
cgu_page = pages[-1]
syntax_page = Page(name="Aide_sur_la_syntaxe")
syntax_page.save(force_lock=True)
services_page = Page(name="Services")
services_page.save(force_lock=True)
index_page = Page(name="Index")
index_page.save(force_lock=True)
page_revs = [
PageRev(page=cgu_page, title="Règlement informatique", content=""),
PageRev(
page=syntax_page,
title="Aide sur la syntaxe",
content=(
self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md"
).read_text(),
),
PageRev(
page=services_page,
title="Services",
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
),
PageRev(
page=index_page, title="Wiki index", content="Welcome to the wiki page!"
),
]
for rev in page_revs:
rev.author_id = settings.SITH_ROOT_USER_ID
rev.revision = 1
PageRev.objects.bulk_create(page_revs)
groups.public.viewable_page.set(
[syntax_page, services_page, index_page, cgu_page]
)
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
beers_type, cotis_type, refill_type, verre_type = (
ProductType.objects.bulk_create(
[
ProductType(name="Bières bouteilles"),
ProductType(name="Cotisations"),
ProductType(name="Rechargements"),
ProductType(name="Verre"),
]
)
)
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=cotis_type,
purchase_price=15,
club=clubs.ae,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=cotis_type,
purchase_price="28",
club=clubs.ae,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=refill_type,
purchase_price=15,
club=clubs.ae,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre_type,
purchase_price="1",
club=clubs.pdf,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre_type,
purchase_price="-1",
club=clubs.pdf,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price=0,
club=clubs.refound,
)
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
Price.objects.bulk_create(
[
Price(product=cotis, amount=15),
Price(product=cotis2, amount=28),
Price(product=refill, amount=15),
Price(product=barb, amount=1.7),
Price(product=cble, amount=1.7),
Price(product=cors, amount=1.7),
Price(product=carolus, amount=1.7),
Price(product=cons, amount=1),
Price(product=dcons, amount=-1),
]
)
)
Price.groups.through.objects.bulk_create(
[
Price.groups.through(price=cotis, group=groups.subscribers),
Price.groups.through(price=cotis2, group=groups.subscribers),
Price.groups.through(price=refill, group=groups.subscribers),
Price.groups.through(price=barb, group=groups.subscribers),
Price.groups.through(price=cble, group=groups.subscribers),
Price.groups.through(price=cors, group=groups.subscribers),
Price.groups.through(price=carolus, group=groups.subscribers),
Price.groups.through(price=cotis, group=groups.old_subscribers),
Price.groups.through(price=cotis2, group=groups.old_subscribers),
Price.groups.through(price=cons, group=groups.old_subscribers),
Price.groups.through(price=dcons, group=groups.old_subscribers),
]
)
def _create_profile_pict(self, user: User):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP")
@@ -777,6 +748,110 @@ class Command(BaseCommand):
)
s.save()
def _create_clubs(self) -> PopulatedClubs:
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
ae.board_group.permissions.add(
*Permission.objects.filter(
codename__in=[
"view_subscription",
"add_subscription",
"add_membership",
"view_hidden_user",
"add_refilling",
]
)
)
pdf = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=ae
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=ae
)
roles = []
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
board_roles = [
"Trésorier⸱e",
"Secrétaire",
"Respo Info",
"Respo Com",
"Membre du bureau",
]
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
for club in ae, pdf, troll, refound:
for i, role in enumerate(presidency_roles):
roles.append(
ClubRole(
club=club, order=i, name=role, is_presidency=True, is_board=True
)
)
for i, role in enumerate(board_roles, start=len(presidency_roles)):
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
for i, role in enumerate(
simple_roles, start=len(presidency_roles) + len(board_roles)
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
insta, fb, discord, _ = LinkType.objects.bulk_create(
[
LinkType(
name="instagram",
icon="fa-brands fa-square-instagram",
url_base="https://www.instagram.com",
),
LinkType(
name="facebook",
icon="fa-brands fa-facebook",
url_base="https://www.facebook.com",
),
LinkType(
name="discord",
icon="fa-brands fa-discord",
url_base="https://discord.gg",
),
LinkType(name="generic", icon="fa fa-link", url_base=""),
]
)
ClubLink.objects.bulk_create(
[
ClubLink(
name="insta AE",
url="https://www.instagram.com/ae_utbm/",
club=ae,
link_type=insta,
),
ClubLink(
name="insta activités AE",
url="https://www.instagram.com/activites_ae/",
club=ae,
link_type=insta,
),
ClubLink(
name="facebook AE",
url="https://www.facebook.com/ae_utbm",
club=ae,
link_type=fb,
),
ClubLink(
name="Discord",
url="https://discord.gg/QvTm3XJrHR",
club=ae,
link_type=discord,
),
]
)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()
@@ -789,16 +864,16 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
*list(
perms.filter(
codename__in=[
"view_uv",
"view_uvcomment",
"add_uvcommentreport",
"view_ue",
"view_uecomment",
"add_uecommentreport",
"view_user",
"view_picture",
"view_album",
@@ -874,7 +949,7 @@ class Command(BaseCommand):
pedagogy_admin.permissions.add(
*list(
perms.filter(content_type__app_label="pedagogy")
.exclude(codename__in=["change_uvcomment"])
.exclude(codename__in=["change_uecomment"])
.values_list("pk", flat=True)
)
)
@@ -907,3 +982,132 @@ class Command(BaseCommand):
BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", 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]))
+95 -50
View File
@@ -1,3 +1,4 @@
import math
import random
from datetime import date, timedelta
from datetime import timezone as tz
@@ -11,19 +12,20 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
from core.models import Group, User
from club.models import Club, ClubRole, Membership
from core.models import Group, User, UserBan
from counter.models import (
Counter,
Customer,
Permanency,
Price,
Product,
ProductType,
Refilling,
Selling,
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UV
from pedagogy.models import UE
from subscription.models import Subscription
@@ -34,12 +36,18 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs)
self.faker = Faker("fr_FR")
def add_arguments(self, parser):
parser.add_argument(
"-n", "--nb-users", help="Number of users to create", type=int, default=600
)
def handle(self, *args, **options):
if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...")
users = self.create_users()
users = self.create_users(options["nb_users"])
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
@@ -74,11 +82,11 @@ class Command(BaseCommand):
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
self.stdout.write("Creating uvs...")
self.create_uvs()
self.create_ues()
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100)
sellers = random.sample(users, len(users) // 10)
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -87,7 +95,9 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self) -> list[User]:
def create_users(self, nb_users: int = 600) -> list[User]:
# Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
users = [
User(
@@ -104,7 +114,7 @@ class Command(BaseCommand):
address=self.faker.address(),
password=password,
)
for _ in range(600)
for _ in range(nb_users)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
@@ -114,14 +124,45 @@ class Command(BaseCommand):
public_group.users.add(*users)
return users
def create_bans(self, users: list[User]):
ban_groups = [
settings.SITH_GROUP_BANNED_COUNTER_ID,
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
]
UserBan.objects.bulk_create(
[
UserBan(
user=user,
ban_group_id=i,
reason=self.faker.sentence(),
expires_at=make_aware(self.faker.future_datetime("+1y")),
)
for user in users
for i in random.sample(ban_groups, k=random.randint(1, len(ban_groups)))
]
)
def create_subscriptions(self, users: list[User]):
subscription_types = [
"un-semestre",
"deux-semestres",
"cursus-tronc-commun",
"cursus-branche",
]
def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
sub = Subscription(member=_user, payment_method=payment_method)
sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
sub.subscription_end = sub.compute_end(duration)
return sub
subscription_type = random.choice(subscription_types)
s = Subscription(
member=_user,
payment_method=payment_method,
subscription_type=subscription_type,
)
duration = settings.SITH_SUBSCRIPTIONS[subscription_type]["duration"]
s.subscription_start = s.compute_start(d=start_date, duration=duration)
s.subscription_end = s.compute_end(duration)
return s
subscriptions = []
customers = []
@@ -150,20 +191,25 @@ class Command(BaseCommand):
Customer.objects.bulk_create(customers, ignore_conflicts=True)
def make_club(self, club: Club, members: list[User], old_members: list[User]):
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
roles: list[ClubRole] = list(club.roles.all())
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
important_roles = [r for r in roles if r.is_board]
important_roles.sort(key=lambda r: r.order)
simple_board_role = important_roles.pop()
member_roles = [r for r in roles if not r.is_board]
user_idx = 0
while (role := next(roles)) > 2:
for _role in important_roles:
# one member for each major role
yield users[user_idx], role
yield users[user_idx], _role
user_idx += 1
for _ in range(int(0.3 * (len(users) - user_idx))):
# 30% of the remaining in the board
yield users[user_idx], 2
yield users[user_idx], simple_board_role
user_idx += 1
for remaining in users[user_idx + 1 :]:
# everything else is a simple member
yield remaining, 1
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
memberships = []
old_members = old_members.copy()
@@ -175,24 +221,19 @@ class Command(BaseCommand):
start_date=start,
end_date=self.faker.past_date(start),
user=old,
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
role=random.choice(roles),
club=club,
)
)
for member, role in zip_roles(members):
start = self.faker.past_date("-1y")
memberships.append(
Membership(
start_date=start,
user=member,
role=role,
club=club,
)
Membership(start_date=start, user=member, role=role, club=club)
)
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_uvs(self):
def create_ues(self):
root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"]
branches = ["TC", "GMC", "GI", "EDIM", "E", "IMSI", "HUMA"]
@@ -207,7 +248,7 @@ class Command(BaseCommand):
+ str(random.randint(10, 90))
)
uvs.append(
UV(
UE(
code=code,
author=root,
manager=random.choice(teachers),
@@ -229,7 +270,7 @@ class Command(BaseCommand):
hours_TE=random.randint(15, 40),
)
)
UV.objects.bulk_create(uvs, ignore_conflicts=True)
UE.objects.bulk_create(uvs, ignore_conflicts=True)
def create_products(self):
categories = [
@@ -256,6 +297,7 @@ class Command(BaseCommand):
# 2/3 of the products are owned by AE
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
products = []
prices = []
buying_groups = []
selling_places = []
for _ in range(200):
@@ -266,25 +308,28 @@ class Command(BaseCommand):
product_type=random.choice(categories),
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
purchase_price=price,
selling_price=price,
special_selling_price=price - min(0.5, price),
club=random.choice(clubs),
limit_age=0 if random.random() > 0.2 else 18,
archived=bool(random.random() > 0.7),
archived=self.faker.boolean(60),
)
products.append(product)
# there will be products without buying groups
# but there are also such products in the real database
buying_groups.extend(
Product.buying_groups.through(product=product, group=group)
for group in random.sample(groups, k=random.randint(0, 3))
)
for i in range(random.randint(0, 3)):
product_price = Price(
amount=price, product=product, is_always_shown=self.faker.boolean()
)
# prices for non-subscribers will be higher than for subscribers
price *= 1.2
prices.append(product_price)
buying_groups.append(
Price.groups.through(price=product_price, group=groups[i])
)
selling_places.extend(
Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4))
)
Product.objects.bulk_create(products)
Product.buying_groups.through.objects.bulk_create(buying_groups)
Price.objects.bulk_create(prices)
Price.groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places)
def create_sales(self, sellers: list[User]):
@@ -298,7 +343,7 @@ class Command(BaseCommand):
)
)
)
products = list(Product.objects.all())
prices = list(Price.objects.select_related("product").all())
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
)
@@ -308,14 +353,14 @@ class Command(BaseCommand):
# the longer the customer has existed, the higher the mean of nb_products
mu = 5 + (now().year - customer.since.year) * 2
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
favoured_products = random.sample(products, k=(random.randint(1, 5)))
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
favoured_counter = random.choice(counters)
this_customer_sales = []
for _ in range(nb_sales):
product = (
random.choice(favoured_products)
price = (
random.choice(favoured_prices)
if random.random() > 0.7
else random.choice(products)
else random.choice(prices)
)
counter = (
favoured_counter
@@ -324,11 +369,11 @@ class Command(BaseCommand):
)
this_customer_sales.append(
Selling(
product=product,
product=price.product,
counter=counter,
club_id=product.club_id,
club_id=price.product.club_id,
quantity=random.randint(1, 5),
unit_price=product.selling_price,
unit_price=price.amount,
seller=random.choice(sellers),
customer=customer,
date=make_aware(
@@ -350,7 +395,6 @@ class Command(BaseCommand):
date=make_aware(
self.faker.date_time_between(customer.since, localdate())
),
is_validated=True,
)
)
sales.extend(this_customer_sales)
@@ -389,8 +433,9 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100)
most_actives = random.sample(forumers, 10)
users = list(User.objects.all())
forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories))
-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
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
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):
"""Invert `is_manually_manageable`.
@@ -1,10 +1,14 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30
from typing import TYPE_CHECKING
import django.contrib.auth.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_ban_groups(apps: StateApps, schema_editor):
@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-09 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0047_alter_notification_date_alter_notification_type")]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [("view_hidden_user", "Can view hidden users")],
"verbose_name": "user",
"verbose_name_plural": "users",
},
),
migrations.RenameField(
model_name="user", old_name="is_subscriber_viewable", new_name="is_viewable"
),
migrations.AlterField(
model_name="user",
name="is_viewable",
field=models.BooleanField(
default=True,
verbose_name="Profile visible by subscribers",
help_text=(
"If you disable this option, only admin users "
"will be able to see your profile."
),
),
),
]
@@ -0,0 +1,37 @@
# Generated by Django 5.2.12 on 2026-03-14 08:39
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0048_alter_user_options")]
operations = [
migrations.AddField(
model_name="user",
name="whitelisted_users",
field=models.ManyToManyField(
blank=True,
help_text=(
"Even if this profile is hidden, "
"the users in this list will still be able to see it."
),
related_name="visible_by_whitelist",
to=settings.AUTH_USER_MODEL,
verbose_name="whitelisted users",
),
),
migrations.AlterField(
model_name="preferences",
name="show_my_stats",
field=models.BooleanField(
default=False,
help_text=(
"Allow subscribers (or whitelisted users "
"if your profile is hidden) to access your AE account stats."
),
verbose_name="show your stats to others",
),
),
]
@@ -0,0 +1,51 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile")
SithFile.objects.update(updated_at=F("date"))
class Migration(migrations.Migration):
dependencies = [("core", "0049_user_whitelisted_users")]
operations = [
migrations.AlterField(
model_name="sithfile",
name="moderator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_files",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
migrations.AlterField(
model_name="sithfile",
name="owner",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_files",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
migrations.AddField(
model_name="sithfile",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-09-24 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0050_alter_sithfile_moderator")]
operations = [
migrations.AddField(
model_name="preferences",
name="show_my_timetable",
field=models.BooleanField(
default=True,
help_text=(
"Allow subscribers to see your timetable and "
"to compare it to theirs (if you have filled it)"
),
verbose_name="show your timetable to others",
),
),
]
Loaded 100 of 428 files, more files were not shown because too many files have changed in this diff. Show more