166 Commits

Author SHA1 Message Date
imperosol
3700fbb214 remove settings.SITH_MAXIMUM_FREE_ROLE 2026-03-21 22:38:35 +01:00
imperosol
b2b8d24003 adapt club members pages to new club roles framework 2026-03-21 22:38:35 +01:00
imperosol
d3c6402e28 adapt tests to new club roles framework 2026-03-21 22:38:35 +01:00
imperosol
74679c86d1 change on_delete constraint for club pages 2026-03-20 16:28:51 +01:00
imperosol
5275c30480 adapt populate and populate_more 2026-03-20 16:28:51 +01:00
imperosol
7e6d5fc435 add ClubRole model 2026-03-20 16:28:51 +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
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
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
4391f63de8 Merge pull request #1292 from ae-utbm/merge-back
Merge back
2026-02-13 15:19:35 +01:00
Sli
8b7eb6edf9 Merge branch 'master' into taiste 2026-02-13 15:16:26 +01:00
f8cda3a31d Merge pull request #1291 from ae-utbm/update-hey-api
Update hey-api
2026-02-13 14:32:18 +01:00
Sli
433d29fcdb Update hey-api 2026-02-13 14:27:52 +01:00
thomas girod
514b8bbec7 Merge pull request #1290 from ae-utbm/update-deps
Update deps
2026-02-13 14:21:15 +01:00
imperosol
84033f37cf update BiomeJS 2026-02-13 14:09:27 +01:00
imperosol
e71f76ea91 remove shorten.min.js 2026-02-13 13:13:40 +01:00
imperosol
530475c4ee update JS dependencies 2026-02-13 11:58:20 +01:00
imperosol
e992bebd68 update python dependencies 2026-02-13 11:52:02 +01:00
thomas girod
8f1c786aa2 Merge pull request #1274 from ae-utbm/remove-club-cache
remove cache calls to fetch user membership
2026-02-11 12:47:06 +01:00
imperosol
c5ae81aae7 update docstrings 2026-02-10 13:08:36 +01:00
imperosol
252acc64c1 remove cache calls to fetch user membership 2026-02-10 13:08:36 +01:00
thomas girod
0d2430a5d4 Merge pull request #1288 from ae-utbm/cb-message
explanation message when eboutic bank payments are disabled
2026-02-09 19:37:12 +01:00
imperosol
b6f77dea97 apply review suggestion 2026-02-09 15:24:52 +01:00
imperosol
df2e65a991 explanation message when eboutic bank payments are disabled 2026-02-08 16:21:09 +01:00
thomas girod
de776045a8 Merge pull request #1287 from ae-utbm/ruff
Update Ruff
2026-02-04 03:10:33 +01:00
imperosol
367ea703ce remove fmt: off 2026-02-03 21:23:34 +01:00
imperosol
bdcb802da8 apply ruff rule PLW0108 2026-02-03 21:12:14 +01:00
imperosol
4e4b5a39f7 update ruff 2026-02-03 21:11:13 +01:00
51534629ed Merge pull request #1279 from ae-utbm/fix_elections
fix: bad value for blank vote and better flow for invalid form
2026-02-03 15:18:33 +01:00
Sli
c042c8e8a3 fix: bad value for blank vote and better flow for invalid form
* Add an error message when looking at a public election without being logged in
* Add correct value for blank vote on single vote field
* Redirect to view with an error message if an invalid form has been submitted
2026-02-03 10:05:36 +01:00
thomas girod
5af894060a Merge pull request #1273 from ae-utbm/fix-counter
fix: wrong quantity displayed on click after removing item
2026-01-21 22:42:27 +01:00
679b8dac1c Merge pull request #1278 from ae-utbm/download-picture-fix
Fix image file generation on user image download
2026-01-21 22:04:09 +01:00
Sli
e9eb3dc17d Fix image file generation on user image download
* Add image id on the name to avoid error with images with the exact same date (if we have epoch for example)
* Fix album name due to schema change not reflected here
2026-01-07 17:49:14 +01:00
8c6f7c82c9 Merge pull request #1277 from ae-utbm/pedagogy
Don't craft urls in pedagogy frontend
2025-12-25 17:01:48 +01:00
Sli
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
Sli
d484971dad Fix pagination on matmat, don't allow empty matmat search and add htmx pagination 2025-12-17 09:21:52 +01:00
Sli
f24e39ccb7 Fix aria-busy when going backward on pages with htmx pagination 2025-12-17 00:01:48 +01:00
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
Sli
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
221 changed files with 7707 additions and 6312 deletions

3
.gitignore vendored
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

View File

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

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

View File

@@ -7,20 +7,34 @@
},
"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
"recommended": true,
"style": {
"useNamingConvention": "error"
},
"performance": {
"noNamespaceImport": "error"
},
"suspicious": {
"noConsole": {
"level": "error",
"options": { "allow": ["error", "warn"] }
}
},
"correctness": {
"noUnusedVariables": "error",
"noUndeclaredVariables": "error",
"noUndeclaredDependencies": "error"
}
}
},
"javascript": {

View File

@@ -14,7 +14,7 @@
#
from django.contrib import admin
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
@admin.register(Club)
@@ -30,6 +30,20 @@ class ClubAdmin(admin.ModelAdmin):
)
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name",)
autocomplete_fields = ("club",)
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):
list_display = ("user", "club", "role", "start_date", "end_date")

View File

@@ -6,9 +6,15 @@ 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 CanAccessLookup, CanView, HasPerm
from club.models import Club, Membership
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
from club.schemas import (
ClubSchema,
ClubSearchFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
from core.models import User
@api_controller("/club")
@@ -33,8 +39,28 @@ 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
)
@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")
)

View File

@@ -23,13 +23,12 @@
#
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.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, ClubRole, Mailing, MailingSubscription, Membership
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
@@ -37,6 +36,7 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubEditForm(forms.ModelForm):
@@ -191,6 +191,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 +214,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 +232,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):
def available_roles(self) -> QuerySet[ClubRole]:
"""The greatest role 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,7 +250,7 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser}
@cached_property
def max_available_role(self):
def available_roles(self):
"""The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role.
@@ -253,13 +258,13 @@ class ClubAddMemberForm(ClubMemberForm):
Other users cannot attribute roles with this form
"""
if self.request_user.has_perm("club.add_membership"):
return settings.SITH_CLUB_ROLES_ID["President"]
return self.club.roles.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 self.club.roles.all()
return self.club.roles.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 +288,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)
def clean(self):
"""Check that the user is subscribed and isn't already in the club."""

View File

@@ -2,12 +2,15 @@
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
# 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 +49,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)]
)

View File

@@ -0,0 +1,138 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
PRESIDENT_ROLE = 10
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 == PRESIDENT_ROLE,
club_id=club_id,
order=PRESIDENT_ROLE - role,
)
updates.append(When(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 in which this role exists",
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.AddConstraint(
model_name="clubrole",
constraint=models.CheckConstraint(
condition=models.Q(
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_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
]

View File

@@ -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",
),
),
]

View File

@@ -26,18 +26,17 @@ from __future__ import annotations
from typing import Iterable, Self
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
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,7 +89,7 @@ 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
@@ -139,9 +138,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."""
@@ -187,9 +184,6 @@ class Club(models.Model):
self.page.save(force_lock=True)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}")
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
@@ -210,29 +204,93 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user.
@cached_property
def current_members(self) -> list[Membership]:
return list(
self.members.ongoing().select_related("user", "role").order_by("-role")
)
Note:
The result is cached.
"""
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user."""
if user.is_anonymous:
return None
membership = cache.get(f"membership_{self.id}_{user.id}")
if membership == "not_member":
return None
if membership is None:
membership = self.members.filter(user=user, end_date=None).first()
if membership is None:
cache.set(f"membership_{self.id}_{user.id}", "not_member")
else:
cache.set(f"membership_{self.id}_{user.id}", membership)
return membership
return next((m for m in self.current_members if m.user_id == user.id), None)
def has_rights_in_club(self, user: User) -> bool:
return user.is_in_group(pk=self.board_group_id)
class ClubRole(OrderedModel):
club = models.ForeignKey(
Club,
verbose_name=_("club"),
help_text=_("The club in which this role exists"),
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."
),
)
order_with_respect_to = "club"
class Meta(OrderedModel.Meta):
verbose_name = _("club role")
verbose_name_plural = _("club roles")
abstract = False
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",
)
]
def __str__(self):
return self.name
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def get_absolute_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club_id})
def clean(self):
errors = []
if self.is_presidency and not self.is_board:
errors.append(
ValidationError(
_(
"Role %(name)s was declared as a presidency role "
"without being a board role"
)
% {"name": self.name}
)
)
if (
self.is_board
and self.club.roles.filter(is_board=False, order__lt=self.order).exists()
):
errors.append(
ValidationError(
_("Board role %(role)s cannot be placed below a member role")
% {"role": self.name}
)
)
if errors:
raise ValidationError(errors)
return super().clean()
class MembershipQuerySet(models.QuerySet):
def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet."""
@@ -245,9 +303,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 +329,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 +380,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 +408,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 +430,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 +444,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,12 +459,15 @@ 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(
@@ -503,7 +539,7 @@ class Membership(models.Model):
group_id=membership.club.members_group_id,
)
)
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
if membership.role.is_board:
club_groups.append(
User.groups.through(
user_id=membership.user_id,

View File

@@ -1,18 +1,16 @@
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 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):
@@ -41,12 +39,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 +62,14 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]
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

View File

@@ -1,7 +1,7 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")

View File

@@ -45,7 +45,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 -%}

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>

View File

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

View File

@@ -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 %}

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 %}

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):

View File

@@ -5,7 +5,7 @@ from django.utils.timezone import localdate
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 subscriber_user
@@ -16,11 +16,19 @@ 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)

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(

View File

@@ -4,7 +4,7 @@ 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
@@ -12,7 +12,12 @@ from core.baker_recipes import subscriber_user
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}),
@@ -32,7 +37,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

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):

View File

@@ -1,20 +1,20 @@
import itertools
from collections.abc import Callable
from datetime import timedelta
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 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
@@ -72,39 +72,25 @@ class TestMembershipQuerySet(TestClub):
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
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 +98,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 +127,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 +162,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 +211,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 +247,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 +263,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 +284,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 +303,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 +319,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 +335,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 +353,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 +372,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 +385,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 +424,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 +437,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 +481,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 +494,20 @@ 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_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 +520,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 +548,35 @@ 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 TestJoinClub:
@pytest.fixture(autouse=True)
@@ -539,55 +584,63 @@ 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]),
_quantity=10,
_bulk_create=True,
)
cls.memberships = baker.make(
Membership,
role=iter(roles),

View File

@@ -3,9 +3,10 @@ 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 club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User
@@ -16,7 +17,6 @@ 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
@@ -30,10 +30,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")
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

View File

@@ -1,3 +1,6 @@
import csv
import itertools
import pytest
from django.test import Client
from django.urls import reverse
@@ -7,16 +10,20 @@ 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
@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 +43,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,
]

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

View File

@@ -22,25 +22,27 @@
#
#
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
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.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.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import (
@@ -61,11 +63,14 @@ 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.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import 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 +80,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):
@@ -202,7 +209,7 @@ class ClubView(ClubTabsMixin, DetailView):
return kwargs
class ClubRevView(ClubView):
class ClubRevView(LoginRequiredMixin, ClubView):
"""Display a specific page revision."""
def dispatch(self, request, *args, **kwargs):
@@ -216,26 +223,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 +317,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 +342,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,8 +371,8 @@ 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")
)
}
@@ -399,33 +406,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 +460,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 +489,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"),
],
@@ -556,33 +544,17 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club"
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 +566,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 +723,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)

View File

@@ -4,15 +4,16 @@ 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.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest
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 com.models import News, NewsDate
from core.models import User
@@ -42,9 +43,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 +54,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")

View File

@@ -1,9 +1,9 @@
from datetime import datetime
from typing import Annotated
from ninja import FilterSchema, ModelSchema
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
@@ -11,12 +11,12 @@ 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):

View File

@@ -1,6 +1,4 @@
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 } 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 +6,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -25,6 +25,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
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;
@@ -90,11 +95,13 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
10,
);
}
refreshEvents() {
this.click(); // Remove focus from popup
this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event
this.calendar.refetchEvents();
}
@@ -153,12 +160,24 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}
async getEventSources() {
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)}`,
format: "ics",
className: "internal",
cache: false,
eventDataTransform: tagRecurringEvents,
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}`,
@@ -166,6 +185,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
color: "red",
className: "unpublished",
cache: false,
eventDataTransform: tagRecurringEvents,
},
];
}
@@ -361,6 +381,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();

View File

@@ -1,4 +1,4 @@
import { exportToHtml } from "#core:utils/globals";
import { exportToHtml } from "#core:utils/globals.ts";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,

View File

@@ -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 {
@@ -147,3 +149,28 @@ ics-calendar {
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;
}
}

View File

@@ -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>

View File

@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
@@ -18,16 +17,6 @@ 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")
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):

View File

@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
from model_bakery import baker
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 +214,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)

View File

@@ -240,10 +240,11 @@ 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,
is_viewable=True,
)
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"),
@@ -503,7 +504,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(

View File

@@ -74,9 +74,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)

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Any, 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
@@ -28,6 +28,7 @@ from core.schemas import (
UserSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown")
@@ -72,9 +73,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 +86,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 +109,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 +122,11 @@ class GroupController(ControllerBase):
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DepthValue = Annotated[int, Ge(0), Le(10)]
DEFAULT_DEPTH = 4

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
)

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(

View File

@@ -36,7 +36,7 @@ from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
from club.models import Club, Membership
from club.models import Club, ClubRole, 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
@@ -44,7 +44,7 @@ from core.utils import resize_image
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from election.models import Candidature, Election, ElectionList, Role
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 +62,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] = (
@@ -111,28 +118,16 @@ class Command(BaseCommand):
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",
)
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 +145,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(
@@ -314,54 +310,41 @@ 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),
@@ -378,7 +361,7 @@ class Command(BaseCommand):
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
club=clubs.ae,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
@@ -387,7 +370,7 @@ class Command(BaseCommand):
purchase_price="28",
selling_price="28",
special_selling_price="28",
club=main_club,
club=clubs.ae,
)
refill = Product.objects.create(
name="Rechargement 15 €",
@@ -396,7 +379,7 @@ class Command(BaseCommand):
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
club=clubs.ae,
)
barb = Product.objects.create(
name="Barbar",
@@ -405,7 +388,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
club=clubs.ae,
limit_age=18,
)
cble = Product.objects.create(
@@ -415,7 +398,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
club=clubs.ae,
limit_age=18,
)
cons = Product.objects.create(
@@ -425,7 +408,7 @@ class Command(BaseCommand):
purchase_price="1",
selling_price="1",
special_selling_price="1",
club=main_club,
club=clubs.ae,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
@@ -434,7 +417,7 @@ class Command(BaseCommand):
purchase_price="-1",
selling_price="-1",
special_selling_price="-1",
club=main_club,
club=clubs.ae,
)
cors = Product.objects.create(
name="Corsendonk",
@@ -443,7 +426,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
club=clubs.ae,
limit_age=18,
)
carolus = Product.objects.create(
@@ -453,7 +436,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
club=clubs.ae,
limit_age=18,
)
Product.objects.create(
@@ -462,7 +445,7 @@ class Command(BaseCommand):
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=refound,
club=clubs.refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
@@ -475,7 +458,7 @@ class Command(BaseCommand):
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
@@ -499,7 +482,7 @@ class Command(BaseCommand):
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(main_club.board_group)
el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@@ -572,7 +555,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 +573,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 +589,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 +605,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 +623,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 +643,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:
@@ -777,6 +760,52 @@ class Command(BaseCommand):
)
s.save()
def _create_clubs(self) -> PopulatedClubs:
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"]
)
)
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)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()
@@ -789,16 +818,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 +903,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)
)
)

View File

@@ -11,8 +11,8 @@ 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,
@@ -23,7 +23,7 @@ from counter.models import (
Selling,
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UV
from pedagogy.models import UE
from subscription.models import Subscription
@@ -40,6 +40,7 @@ class Command(BaseCommand):
self.stdout.write("Creating users...")
users = self.create_users()
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
@@ -74,7 +75,7 @@ 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...")
@@ -88,6 +89,8 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self) -> list[User]:
# Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
users = [
User(
@@ -114,14 +117,33 @@ class Command(BaseCommand):
public_group.users.add(*users)
return users
def create_bans(self, users: list[User]):
ban_groups = [
settings.SITH_GROUP_BANNED_COUNTER_ID,
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
]
UserBan.objects.bulk_create(
[
UserBan(
user=user,
ban_group_id=i,
reason=self.faker.sentence(),
expires_at=make_aware(self.faker.future_datetime("+1y")),
)
for user in users
for i in random.sample(ban_groups, k=random.randint(1, len(ban_groups)))
]
)
def create_subscriptions(self, users: list[User]):
def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
sub = Subscription(member=_user, payment_method=payment_method)
sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
sub.subscription_end = sub.compute_end(duration)
return sub
s = Subscription(member=_user, payment_method=payment_method)
s.subscription_start = s.compute_start(d=start_date, duration=duration)
s.subscription_end = s.compute_end(duration)
return s
subscriptions = []
customers = []
@@ -150,20 +172,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 +202,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 +229,7 @@ class Command(BaseCommand):
+ str(random.randint(10, 90))
)
uvs.append(
UV(
UE(
code=code,
author=root,
manager=random.choice(teachers),
@@ -229,7 +251,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 = [
@@ -350,7 +372,6 @@ class Command(BaseCommand):
date=make_aware(
self.faker.date_time_between(customer.since, localdate())
),
is_validated=True,
)
)
sales.extend(this_customer_sales)

View File

@@ -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."
),
),
),
]

View File

@@ -23,12 +23,13 @@
#
from __future__ import annotations
import difflib
import string
import unicodedata
from datetime import timedelta
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Final, Self
from uuid import uuid4
from django.conf import settings
@@ -37,7 +38,6 @@ from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
from django.core.files.base import ContentFile
@@ -54,6 +54,8 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image, ImageOps
from core.utils import get_last_promo
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt
@@ -74,24 +76,13 @@ class Group(AuthGroup):
def get_absolute_url(self) -> str:
return reverse("core:group_list")
def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs)
cache.set(f"sith_group_{self.id}", self)
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
def delete(self, *args, **kwargs) -> None:
super().delete(*args, **kwargs)
cache.delete(f"sith_group_{self.id}")
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
def validate_promo(value: int) -> None:
start_year = settings.SITH_SCHOOL_START_YEAR
delta = (localdate() + timedelta(days=180)).year - start_year
if value < 0 or delta < value:
last_promo = get_last_promo()
if not 0 < value <= last_promo:
raise ValidationError(
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
params={"value": value, "end": delta},
params={"value": value, "end": last_promo},
)
@@ -136,6 +127,15 @@ class UserQuerySet(models.QuerySet):
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
)
def viewable_by(self, user: User) -> Self:
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
return self.filter(is_viewable=True)
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
@@ -271,13 +271,24 @@ class User(AbstractUser):
parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default=""
)
is_subscriber_viewable = models.BooleanField(
_("is subscriber viewable"), default=True
is_viewable = models.BooleanField(
_("Profile visible by subscribers"),
help_text=_(
"If you disable this option, only admin users "
"will be able to see your profile."
),
default=True,
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager()
class Meta(AbstractUser.Meta):
abstract = False
permissions = [
("view_hidden_user", "Can view hidden users"),
]
def __str__(self):
return self.get_display_name()
@@ -345,23 +356,27 @@ class User(AbstractUser):
)
if group_id is None:
return False
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return any(g.id == group_id for g in self.cached_groups)
return group_id in self.all_groups
@cached_property
def cached_groups(self) -> list[Group]:
def all_groups(self) -> dict[int, Group]:
"""Get the list of groups this user is in."""
return list(self.groups.all())
additional_groups = []
if self.is_subscribed:
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
if self.is_superuser:
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
qs = self.groups.all()
if additional_groups:
# This is somewhat counter-intuitive, but this query runs way faster with
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
qs = qs.union(Group.objects.filter(id__in=additional_groups))
return {g.id: g for g in qs}
@cached_property
def is_root(self) -> bool:
if self.is_superuser:
return True
root_id = settings.SITH_GROUP_ROOT_ID
return any(g.id == root_id for g in self.cached_groups)
return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
@cached_property
def is_board_member(self) -> bool:
@@ -551,8 +566,12 @@ class User(AbstractUser):
def can_be_edited_by(self, user):
return user.is_root or user.is_board_member
def can_be_viewed_by(self, user):
return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
def can_be_viewed_by(self, user: User) -> bool:
return (
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable)
)
def get_mini_item(self):
return """
@@ -1084,10 +1103,7 @@ class PageQuerySet(models.QuerySet):
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
groups_ids = [g.id for g in user.cached_groups]
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
return self.filter(view_groups__in=user.all_groups)
# This function prevents generating migration upon settings change
@@ -1319,6 +1335,9 @@ class PageRev(models.Model):
The content is in PageRev.title and PageRev.content .
"""
MERGE_TIME_THRESHOLD: Final[timedelta] = timedelta(minutes=20)
MERGE_DIFF_THRESHOLD: Final[float] = 0.2
revision = models.IntegerField(_("revision"))
title = models.CharField(_("page title"), max_length=255, blank=True)
content = models.TextField(_("page content"), blank=True)
@@ -1358,7 +1377,33 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
return self.page.owner_group_id in user.all_groups
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.
The result is a float in [0; 1], 0 meaning the contents are entirely different,
and 1 they are strictly the same.
"""
# cf. https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
return difflib.SequenceMatcher(None, self.content, text).quick_ratio()
def should_merge(self, other: Self) -> bool:
"""Return True if `other` should be merged into `self`, else False.
It's considered the other revision should be merged into this one if :
- it was made less than 20 minutes after
- by the same author
- with a similarity ratio higher than 80%
"""
return (
not self._state.adding # cannot merge if the original rev doesn't exist
and self.author == other.author
and (other.date - self.date) < self.MERGE_TIME_THRESHOLD
and (not other._state.adding or other.revision == self.revision + 1)
and self.similarity_ratio(other.content) >= (1 - other.MERGE_DIFF_THRESHOLD)
)
def get_notification_types():

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
@@ -8,12 +9,14 @@ from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import is_image
from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)]
class UploadedImage(UploadedFile):
@@ -107,7 +110,11 @@ class GroupSchema(ModelSchema):
class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)]
search: Annotated[str, MinLen(1)] | None = None
role: Annotated[str, FilterLookup("role__icontains")] | None = None
department: str | None = None
promo: int | None = None
date_of_birth: datetime | None = None
exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]")
)
@@ -136,6 +143,13 @@ class UserFilterSchema(FilterSchema):
return Q()
return ~Q(id__in=value)
@field_validator("promo", mode="after")
@classmethod
def validate_promo(cls, value: int) -> int:
if not 0 < value <= get_last_promo():
raise ValueError(f"{value} is not a valid promo")
return value
class MarkdownSchema(Schema):
text: str

View File

@@ -1,7 +1,7 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { limitedChoices } from "#core:alpine/limited-choices.ts";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications.ts";
Alpine.plugin([sort, limitedChoices]);
Alpine.magic("notifications", notificationPlugin);

View File

@@ -56,7 +56,7 @@ export function limitedChoices(Alpine: AlpineType) {
effect(() => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value);
maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed

View File

@@ -1,4 +1,3 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type {
RecursivePartial,
@@ -7,6 +6,7 @@ import type {
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
@@ -29,7 +29,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue) ?? null;
this.delay = Number.parseInt(newValue, 10) ?? null;
break;
}
case "placeholder": {
@@ -37,11 +37,11 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
this.max = Number.parseInt(newValue, 10) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
this.minCharNumberForSearch = Number.parseInt(newValue, 10) ?? 0;
break;
}
default: {

View File

@@ -1,20 +1,19 @@
import "tom-select/dist/css/tom-select.default.css";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type GroupSchema,
type SithFileSchema,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
} from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import {
type GroupSchema,
groupSearchGroup,
type SithFileSchema,
sithfileSearchFiles,
type UserProfileSchema,
userSearchUsers,
} from "#openapi";
@registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
@registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") {
@@ -26,7 +26,6 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card"));
});
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();

View File

@@ -1,6 +1,6 @@
import { registerComponent } from "#core:utils/web-components";
import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components.ts";
@registerComponent("ui-tab")
export class Tab extends HTMLElement {

View File

@@ -0,0 +1,77 @@
interface Config {
/**
* The prefix of the formset, in case it has been changed.
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
*/
prefix?: string;
}
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
document.addEventListener("alpine:init", () => {
/**
* Alpine data element to allow the dynamic addition of forms to a formset.
*
* To use this, you need :
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
* - a template containing the empty form
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
* noted by `x-ref="formTemplate"`
* - a button with `@click="addForm"`
* - you may also have one or more buttons with `@click="removeForm(element)"`,
* where `element` is the HTML element containing the form.
*
* For an example of how this is used, you can have a look to
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace("__prefix__", this.nbForms.toString());
el.id = el.id.replace("__prefix__", this.nbForms.toString());
}
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
}
inputs[0].focus();
this.nbForms += 1;
},
removeForm(container: HTMLDivElement) {
container.remove();
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace(/\d+/, i.toString());
el.id = el.id.replace(/\d+/, i.toString());
}
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
}
}
},
}));
});

View File

@@ -1,4 +1,4 @@
import { exportToHtml } from "#core:utils/globals";
import { exportToHtml } from "#core:utils/globals.ts";
exportToHtml("showMenu", () => {
const navbar = document.getElementById("navbar-content");

View File

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

View File

@@ -1,9 +1,9 @@
import {
type Placement,
autoPlacement,
computePosition,
flip,
offset,
type Placement,
size,
} from "@floating-ui/dom";

View File

@@ -1,11 +1,11 @@
import htmx from "htmx.org";
document.body.addEventListener("htmx:beforeRequest", (event) => {
event.target.ariaBusy = true;
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:afterRequest", (event) => {
event.originalTarget.ariaBusy = null;
document.body.addEventListener("htmx:beforeSwap", (event) => {
event.detail.target.ariaBusy = null;
});
Object.assign(window, { htmx });

View File

@@ -1,6 +1,6 @@
import { exportToHtml } from "#core:utils/globals";
// biome-ignore lint/style/noNamespaceImport: this is the recommended way from the documentation
// biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation
import * as Sentry from "@sentry/browser";
import { exportToHtml } from "#core:utils/globals.ts";
interface LoggedUser {
name: string;

View File

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

View File

@@ -1,4 +1,3 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
@@ -6,7 +5,8 @@ import cytoscape, {
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
@@ -200,7 +200,7 @@ document.addEventListener("alpine:init", () => {
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop));
const value = Number.parseInt(initialUrlParams.get(prop), 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}

View File

@@ -1,5 +1,5 @@
import { client, type Options } from "#openapi";
import type { Client, RequestResult, TDataShape } from "#openapi:client";
import { type Options, client } from "#openapi";
export interface PaginatedResponse<T> {
count: number;

View File

@@ -1,4 +1,4 @@
import type { NestedKeyOf } from "#core:utils/types";
import type { NestedKeyOf } from "#core:utils/types.ts";
interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */

View File

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

View File

@@ -115,7 +115,6 @@ blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {

View File

@@ -21,6 +21,8 @@ $secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);

View File

@@ -65,7 +65,7 @@ footer.bottom-links {
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: black 0 8px 15px;
box-shadow: $shadow-color 0 0 15px;
a {
color: $white-color;

View File

@@ -143,6 +143,15 @@ form {
line-height: 1;
white-space: nowrap;
.fields-centered {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
justify-content: center;
}
.helptext {
margin-top: .25rem;
margin-bottom: .25rem;
@@ -745,4 +754,32 @@ form {
background-repeat: no-repeat;
background-size: var(--nf-input-size);
}
&.no-margin {
margin:0;
}
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&.link-red {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
}
font-weight: normal;
font-size: 100%;
margin: auto;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
}

View File

@@ -5,14 +5,10 @@ $text-color: white;
$background-color-hovered: #283747;
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
.header {
box-sizing: border-box;
background-color: $deepblue;
box-shadow: black 0 1px 3px 0,
black 0 4px 8px 3px;
box-shadow: 3px 3px 3px 0 #dfdfdf;
border-radius: 0;
width: 100%;
display: flex;
@@ -100,7 +96,7 @@ $hovered-red-text-color: #ff4d4d;
border-radius: 0;
margin: 0;
box-sizing: border-box;
background-color: transparent;
background-color: $deepblue;
width: 45px;
height: 25px;
padding: 0;
@@ -252,12 +248,15 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-start;
}
a {
color: $text-color;
}
a,
button {
font-size: 100%;
margin: 0;
text-align: right;
color: $text-color;
margin-top: auto;
&:hover {
@@ -269,19 +268,6 @@ $hovered-red-text-color: #ff4d4d;
margin: 0;
display: inline;
}
#logout-form button {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
}
}
@@ -332,7 +318,7 @@ $hovered-red-text-color: #ff4d4d;
padding: 10px;
z-index: 100;
border-radius: 10px;
@include shadow;
box-shadow: 3px 3px 3px 0 #767676;
>ul {
list-style-type: none;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

@@ -271,9 +271,8 @@ body {
/*--------------------------------CONTENT------------------------------*/
#content {
padding: 1.5em 2%;
border-radius: 5px;
box-shadow: black 0 8px 15px;
padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
overflow: auto;
}
@@ -520,7 +519,6 @@ th {
td {
margin: 5px;
border-collapse: collapse;
vertical-align: top;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -7,10 +7,13 @@
.profile {
&-visible {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
gap: 5px;
padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
}
&-pictures {
@@ -111,28 +114,15 @@
}
}
&-fields {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
&-field {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 330px;
min-width: 300px;
@media (max-width: 750px) {
gap: 4px;
max-width: 100%;
}
@@ -145,22 +135,6 @@
}
}
&-label {
text-align: left !important;
}
&-content {
> * {
box-sizing: border-box;
text-align: left !important;
margin: 0;
> * {
text-align: left !important;
}
}
}
textarea {
height: 7rem;
}

View File

@@ -195,18 +195,18 @@
}
}
}
}
&.delete {
margin-top: 10px;
display: block;
text-align: center;
color: orangered;
form .link-like {
margin-top: 10px;
display: block;
text-align: center;
color: orangered;
@media (max-width: 375px) {
position: absolute;
bottom: 0;
right: 0;
}
@media (max-width: 375px) {
position: absolute;
bottom: 0;
right: 0;
}
}
}

View File

@@ -35,8 +35,8 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
@@ -44,18 +44,6 @@
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
<style>
{# background image must be declared here, because the static names are
changed during the static collection step,
which means we must gather them with the `static` template function #}
.header {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
background-position-y: 80%; {# There are more stars in this part of the picture #}
}
body {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
}
</style>
{% endblock %}
</head>

View File

@@ -1,6 +1,6 @@
<header class="header">
<div class="header-logo">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static("core/img/gala25_logo.webp") }}')">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')">
&nbsp;
</a>
<a class="header-logo-text" href="{{ url('core:index') }}">
@@ -61,7 +61,9 @@
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<form id="logout-form" method="post" action="{{ url("core:logout") }}">
{% csrf_token %}
<button type="submit">{% trans %}Logout{% endtrans %}</button>
<button type="submit" class="link-like link-red">
{% trans %}Logout{% endtrans %}
</button>
</form>
</div>
</div>

View File

@@ -23,7 +23,7 @@
<details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('matmat:search') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul>

View File

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

View File

@@ -78,12 +78,6 @@
{% endif %}
{% endmacro %}
{% macro delete_godfather(user, profile, godfather, is_father) %}
{% if user == profile or user.is_root or user.is_board_member %}
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endmacro %}
{% macro paginate_alpine(page, nb_pages) %}
{# Add pagination buttons for ajax based content with alpine
@@ -157,12 +151,13 @@
{% if current_page.has_previous() %}
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.previous_page_number() }}"
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
{%- else -%}
href="?page={{ current_page.previous_page_number() }}"
href="?{{ querystring(page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button>
@@ -180,12 +175,12 @@
{% else %}
<a
{% if use_htmx -%}
hx-get="?page={{ i }}"
hx-get="?{{ querystring(page=i) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ i }}"
href="?{{ querystring(page=i) }}"
{%- endif -%}
>
<button>{{ i }}</button>
@@ -195,12 +190,13 @@
{% if current_page.has_next() %}
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.next_page_number() }}"
hx-get="?{{querystring(page=current_page.next_page_number())}}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
{%- else -%}
href="?page={{ current_page.next_page_number() }}"
href="?{{querystring(page=current_page.next_page_number())}}"
{%- endif -%}
><button>
<i class="fa fa-caret-right"></i>
@@ -249,3 +245,17 @@
}"></div>
{% endif %}
{% endmacro %}
{% macro querystring() %}
{%- for key, values in request.GET.lists() -%}
{%- if key not in kwargs -%}
{%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %}

View File

@@ -1,64 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% if page %}
{{ page.get_display_name() }}
{% elif page_list %}
{% trans %}Page list{% endtrans %}
{% elif new_page %}
{% trans %}Create page{% endtrans %}
{% else %}
{% trans %}Not found{% endtrans %}
{% endif %}
{% endblock %}
{% block metatags %}
{% if page %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% else %}
{{ super() }}
{% endif %}
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page %}
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
{% endif %}
</div>
</div>
<hr>
{% if page %}
{% block page %}
{% endblock %}
{% else %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p><a href="{{ url('core:page_new') }}?page={{ request.resolver_match.kwargs['page_name'] }}">
{% trans %}Create it?{% endtrans %}</a></p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "core/base.jinja" %}
{% block title %}
{{ page.get_display_name() }}
{% endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
</div>
</div>
<hr>
{% block page %}
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "core/page/base.jinja" %}
{% block page %}
{% if revision and revision.id != last_revision.id %}
<h4>
{% trans trimmed rev_id=revision.revision %}
This may not be the last update, you are seeing revision {{ rev_id }}!
{% endtrans %}
</h4>
{% endif %}
{% set current_revision = revision or last_revision %}
<h3>{{ current_revision.title }}</h3>
<div class="page_content">{{ current_revision.content|markdown }}</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "core/page/base.jinja" %}
{% block page %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url('core:page_edit', page_name=page.get_full_name()) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "core/page.jinja" %}
{% extends "core/page/base.jinja" %}
{% from "core/macros_pages.jinja" import page_history %}
{% from "core/page/macros.jinja" import page_history %}
{% block page %}
<h3>{% trans %}Page history{% endtrans %}</h3>

View File

@@ -17,12 +17,3 @@
{%- endfor -%}
</ul>
{% endmacro %}
{% macro page_edit_form(page, form, url, token) %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url }}" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ token }}">
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endmacro %}

View File

@@ -0,0 +1,12 @@
{% extends "core/base.jinja" %}
{% block content %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p>
{# This template is rendered when a PageNotFound error is raised,
so the `exception` context variable should always have a page_name attribute #}
<a href="{{ url('core:page_new') }}?page={{ exception.page_name }}">
{% trans %}Create it?{% endtrans %}
</a>
</p>
{% endblock %}

View File

@@ -1,18 +1,13 @@
{% extends "core/page.jinja" %}
{% extends "core/page/base.jinja" %}
{% block content %}
{% if page %}
{{ super() }}
{% endif %}
{% block page %}
<h2>{% trans %}Page properties{% endtrans %}</h2>
<form action="" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% if page %}
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a>
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends "core/page.jinja" %}
{% block page %}
{% if rev %}
<h4>{% trans rev_id=rev.revision %}This may not be the last update, you are seeing revision {{ rev_id }}!{% endtrans %}</h4>
<h3>{{ rev.title }}</h3>
<div class="page_content">{{ rev.content|markdown }}</div>
{% else %}
{% if page.revisions.last() %}
<h3>{{ page.revisions.last().title }}</h3>
<div class="page_content">{{ page.revisions.last().content|markdown }}</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -1,9 +0,0 @@
{% extends "core/page.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block page %}
{{ page_edit_form(page, form, url('core:page_edit', page_name=page.get_full_name()), csrf_token) }}
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block content %}
{% if target %}
<p>{% trans user=target.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
<p>{% trans user=form.user.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
{% endif %}
<form method="post" action="">
{% csrf_token %}

View File

@@ -9,19 +9,17 @@
{% block content %}
<h4>{% trans %}Users{% endtrans %}</h4>
<ul>
{% for i in result.users %}
{% if user.can_view(i) %}
<li>
{{ user_link_with_pict(i) }}
</li>
{% endif %}
{% for user in users %}
<li>
{{ user_link_with_pict(user) }}
</li>
{% endfor %}
</ul>
<h4>{% trans %}Clubs{% endtrans %}</h4>
<ul>
{% for i in result.clubs %}
{% for club in clubs %}
<li>
<a href="{{ url("club:club_view", club_id=i.id) }}">{{ i }}</a>
<a href="{{ url("club:club_view", club_id=club.id) }}">{{ club }}</a>
</li>
{% endfor %}
</ul>

View File

@@ -17,18 +17,29 @@
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td>
<td></td>
<td></td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr>
</thead>
<tbody>
{% for m in profile.memberships.filter(end_date=None).all() %}
{% for m in profile.memberships.ongoing().select_related("role") %}
<tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.role.name }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %}
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
<td>
<form
method="post"
action="{{ url('club:membership_set_old', membership_id=m.id) }}"
class="no-margin"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Mark as old{% endtrans %}" />
</form>
</td>
{% endif %}
{% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
@@ -48,14 +59,16 @@
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr>
</thead>
<tbody>
{% for m in profile.memberships.exclude(end_date=None).all() %}
{% for m in profile.memberships.ongoing().select_related("role") %}
<tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.role.name }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>

View File

@@ -114,14 +114,14 @@
{# All fields #}
<div class="profile-fields">
<div class="fields-centered">
{%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- continue -%}
{%- endif -%}
<div class="profile-field">
<div class="profile-field-label">{{ field.label }}</div>
{{ field.label_tag() }}
<div class="profile-field-content">
{{ field }}
{%- if field.errors -%}
@@ -133,10 +133,10 @@
</div>
{# Textareas #}
<div class="profile-fields">
<div class="fields-centered">
{%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field">
<div class="profile-field-label">{{ field.label }}</div>
{{ field.label_tag() }}
<div class="profile-field-content">
{{ field }}
{%- if field.errors -%}
@@ -149,8 +149,13 @@
{# Checkboxes #}
<div class="profile-visible">
{{ form.is_subscriber_viewable }}
{{ form.is_subscriber_viewable.label }}
<div class="row">
{{ form.is_viewable }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div>
<div class="final-actions">

View File

@@ -29,7 +29,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item() | safe }}
</a>
{{ delete_godfather(user, profile, u, True) }}
{% if user == profile or user.is_root or user.is_board_member %}
<form
method="post"
class="no-margin"
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=True) }}"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Delete{% endtrans %}">
</form>
{% endif %}
</li>
{% endfor %}
</ul>
@@ -46,7 +55,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item()|safe }}
</a>
{{ delete_godfather(user, profile, u, False) }}
{% if user == profile or user.is_root or user.is_board_member %}
<form
method="post"
class="no-margin"
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=False) }}"
>
{% csrf_token %}
<input type="submit" class="link-like link-red" value="{% trans %}Delete{% endtrans %}">
</form>
{% endif %}
</li>
{% endfor %}
</ul>

View File

@@ -11,32 +11,35 @@
{% block content %}
<div class="container">
<div class="row">
{% if profile.permanencies %}
{% if total_perm_time %}
<div>
<h3>{% trans %}Permanencies{% endtrans %}</h3>
<div class="flexed">
<div><span>Foyer :</span><span>{{ total_foyer_time }}</span></div>
<div><span>Gommette :</span><span>{{ total_gommette_time }}</span></div>
<div><span>MDE :</span><span>{{ total_mde_time }}</span></div>
<div><b>Total :</b><b>{{ total_perm_time }}</b></div>
{% for perm in perm_time %}
<div>
<span>{{ perm["counter__name"] }} :</span>
<span>{{ perm["total"]|format_timedelta }}</span>
</div>
{% endfor %}
<div><b>Total :</b><b>{{ total_perm_time|format_timedelta }}</b></div>
</div>
</div>
{% endif %}
<div>
<h3>{% trans %}Buyings{% endtrans %}</h3>
<div class="flexed">
<div><span>Foyer :</span><span>{{ total_foyer_buyings }}&nbsp;€</span></div>
<div><span>Gommette :</span><span>{{ total_gommette_buyings }}&nbsp;€</span></div>
<div><span>MDE :</span><span>{{ total_mde_buyings }}&nbsp;€</span></div>
<div><b>Total :</b><b>{{ total_foyer_buyings + total_gommette_buyings + total_mde_buyings }}&nbsp;€</b>
</div>
{% for sum in purchase_sums %}
<div>
<span>{{ sum["counter__name"] }}</span>
<span>{{ sum["total"] }} €</span>
</div>
{% endfor %}
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
</div>
</div>
</div>
<div>
<h3>{% trans %}Product top 10{% endtrans %}</h3>
<h3>{% trans %}Product top 15{% endtrans %}</h3>
<table>
<thead>
<tr>

View File

@@ -184,18 +184,18 @@
</div>
{% endif %}
{% if user.has_perm("pedagogy.add_uv") or user.has_perm("pedagogy.delete_uvcomment") %}
{% if user.has_perm("pedagogy.add_ue") or user.has_perm("pedagogy.delete_uecomment") %}
<div>
<h4>{% trans %}Pedagogy{% endtrans %}</h4>
<ul>
{% if user.has_perm("pedagogy.add_uv") %}
{% if user.has_perm("pedagogy.add_ue") %}
<li>
<a href="{{ url("pedagogy:uv_create") }}">
{% trans %}Create UV{% endtrans %}
<a href="{{ url("pedagogy:ue_create") }}">
{% trans %}Create UE{% endtrans %}
</a>
</li>
{% endif %}
{% if user.has_perm("pedagogy.delete_uvcomment") %}
{% if user.has_perm("pedagogy.delete_uecomment") %}
<li>
<a href="{{ url("pedagogy:moderation") }}">
{% trans %}Moderate comments{% endtrans %}

View File

@@ -55,31 +55,17 @@ def phonenumber(
return value
@register.filter(name="truncate_time")
def truncate_time(value, time_unit):
"""Remove everything in the time format lower than the specified unit.
Args:
value: the value to truncate
time_unit: the lowest unit to display
"""
value = str(value)
return {
"millis": lambda: value.split(".")[0],
"seconds": lambda: value.rsplit(":", maxsplit=1)[0],
"minutes": lambda: value.split(":", maxsplit=1)[0],
"hours": lambda: value.rsplit(" ")[0],
}[time_unit]()
@register.filter(name="format_timedelta")
def format_timedelta(value: datetime.timedelta) -> str:
value = value - datetime.timedelta(microseconds=value.microseconds)
days = value.days
if days == 0:
return str(value)
remainder = value - datetime.timedelta(days=days)
return ngettext(
"%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
"%(nb_days)d day, %(remainder)s",
"%(nb_days)d days, %(remainder)s",
days,
) % {"nb_days": days, "remainder": str(remainder)}

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