1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-07-04 14:52:13 +02:00

Compare commits

..

182 Commits

Author SHA1 Message Date
17be896a99 [permission] Add PermissionVar model 2022-10-10 19:37:51 +02:00
a69573ccdb Fix permission that allows users to create OAuth2 apps
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-08-29 11:21:45 +02:00
5a77a66391 Merge branch 'beta' into 'main'
Friendships

See merge request bde/nk20!200
2022-04-13 12:45:06 +02:00
761fc170eb Update Spanish translation 2022-04-13 12:30:22 +02:00
ac23d7eb54 Generated translation files for de/es (but didn't translate anything) 2022-04-13 12:30:22 +02:00
40e7415062 Added translations for friendships 2022-04-13 12:30:22 +02:00
319405d2b1 Added a message to explain what frendships do
Signed-off-by: Nicolas Margulies <nicomarg@crans.org>
2022-04-13 12:30:22 +02:00
633ab88b04 Linting 2022-04-13 12:30:22 +02:00
e29b42eecc Add permissions related to trusting 2022-04-13 12:30:22 +02:00
dc69faaf1d Better user search to add friendships 2022-04-13 12:30:22 +02:00
442a5c5e36 First proro of trusting, with models and front, but no additional permissions 2022-04-13 12:30:22 +02:00
7ab0fec3bc Added trust model 2022-04-13 12:30:22 +02:00
bd4fb23351 Merge branch 'color_survi' into 'main'
switching to survivalist color

See merge request bde/nk20!199
2022-04-12 20:16:55 +02:00
ee22e9b3b6 fixing color to follow the proper theme 2022-04-12 18:33:22 +02:00
19ae616fb4 switching to survivalist color 2022-04-12 17:40:52 +02:00
b7657ec362 Merge branch 'color_ttlsp' into 'main'
Passage des couleur vers ttlsp

See merge request bde/nk20!197
2022-04-05 15:05:41 +02:00
4d03d9460d Passage des couleurs ttlsp 2022-04-05 14:45:41 +02:00
3633f66a87 Merge branch 'beta' into 'main'
Corrections de bugs

See merge request bde/nk20!195
2022-03-09 15:10:37 +01:00
d43fbe7ac6 Merge branch 'harden' into 'beta'
Harden Django project configuration

See merge request bde/nk20!194
2022-03-09 12:30:23 +01:00
df5f9b5f1e Harden Django project configuration
Set session and CSRF cookies as secure for production.
Set HSTS header to let browser remember HTTPS for 1 year.
2022-03-09 12:12:56 +01:00
4161248bff Add permissions to view/create/change/delete OAuth2 applications
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-03-09 12:06:19 +01:00
58136f3c48 Fix permission checks in the /api/me view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-03-09 11:45:24 +01:00
d9b4e0a9a9 Fix membership tables for clubs without an ending membership date
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-13 17:53:05 +01:00
8563a8d235 Fix membership tables for clubs without an ending membership date
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-13 17:51:22 +01:00
5f69232560 Merge branch 'beta' into 'main'
Optional scopes + small bug fix

See merge request bde/nk20!193
2022-02-12 14:37:58 +01:00
d3273e9ee2 Prepare WEI 2022 (because tests are broken)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-12 14:24:32 +01:00
4e30f805a7 Merge branch 'optional-scopes' into 'beta'
Implement optional scopes : clients can request scopes, but they are not guaranteed to get them

See merge request bde/nk20!192
2022-02-12 13:57:19 +01:00
546e422e64 Ensure some values exist before updating them
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-12 13:56:07 +01:00
9048a416df In the /api/me page, display note, profile and memberships only if we have associated permissions
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 23:25:18 +01:00
8578bd743c Add documentation about optional scopes
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 22:15:06 +01:00
45a10dad00 Refresh token expire between 14 days
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 22:00:08 +01:00
18a1282773 Implement optional scopes : clients can request scopes, but they are not guaranteed to get them
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 21:59:37 +01:00
132afc3d15 Fix scope view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-15 18:59:23 +01:00
6bf16a181a [ansible] Deploy buster-backports repository only on Debian 10
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-15 15:59:58 +01:00
e20df82346 Main branch is now called main
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-15 15:55:13 +01:00
1eb72044c2 Merge branch 'beta' into 'master'
Changements variés et mineurs

Closes #107 et #91

See merge request bde/nk20!191
2021-12-13 21:16:26 +01:00
f88eae924c Use local version of Turbolinks instead of using Cloudfare
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 21:00:34 +01:00
4b6e3ba546 Display club transactions only with note rights, fixes #107
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 20:01:00 +01:00
bf0fe3479f Merge branch 'lock-club-notes' into 'beta'
Verrouillage de notes

See merge request bde/nk20!190
2021-12-13 18:55:03 +01:00
45ba4f9537 Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 18:33:18 +01:00
b204805ce2 Add permissions to (un)lock club notes
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 18:31:36 +01:00
2f28e34cec Fix permissions to lock our own note
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 18:27:24 +01:00
9c8ea2cd41 Club notes can now be locked through web interface
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:48:20 +01:00
41289857b2 Merge branch 'tirage-au-sort' into 'beta'
Boutons

See merge request bde/nk20!189
2021-12-13 17:37:13 +01:00
28a8792c9f [activity] Add space before line breaks in Wiki export of activities
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:30:13 +01:00
58cafad032 Sort buttons by category name instead of id in button list
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:19:10 +01:00
7848cd9cc2 Don't search buttons by prefix
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:18:54 +01:00
d18ccfac23 Sort aliases by normalized name in profile alias view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:18:54 +01:00
e479e1e3a4 Added messages for Hide/Show 2021-10-07 23:06:40 +02:00
82b0c83b1f Added a Hide/Show button for transaction templates, fixes #91 2021-10-07 22:54:01 +02:00
38ca414ef6 Res[pot] can display user information in order to get first/last name in credits
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-06 10:44:24 +02:00
fd811053c7 Commit missing migrations
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-06 10:41:58 +02:00
9d386d1ecf Unauthenticated users can't display activity entry view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-06 10:41:42 +02:00
0bd447b608 Merge branch 'relax_requirements' into 'beta'
Relax requirements and ignore shell.nix

See merge request bde/nk20!187
2021-10-05 15:45:31 +02:00
3f3c93d928 Ignore shell.nix in Git tree
shell.nix is used in Nix to create a specific shell with custom
packages. The name is standardised and need to be in project folder to
ease development tools integrations.
2021-10-05 15:14:56 +02:00
340c90f5d3 Relax requirements
Relax requirements to allow the use of newer versions of dependencies
found in NixPkgs and ArchLinux. Do not limit upper version of
django-extensions as it is not mission critical.
2021-10-05 15:10:20 +02:00
ca2b9f061c Merge branch 'beta' into 'master'
Multiples fix, réparation des pots

Closes #75

See merge request bde/nk20!186
2021-10-05 12:02:03 +02:00
a05dfcbf3d Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-05 11:46:24 +02:00
ba3c0fb18d Fix activity get in invite view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 21:53:35 +02:00
ab69963ea1 Merge branch 'cest-lheure-du-pot' into 'beta'
Améliorations Pot

See merge request bde/nk20!184
2021-10-04 18:45:21 +02:00
654c01631a BDE members can see aliases from other people now
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 18:29:34 +02:00
d94cc2a7ad NameNAN
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 18:26:14 +02:00
69bb38297f Fix membership dates for new memberships, fix tests
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 18:15:07 +02:00
9628560d64 Improve entry search with a debouncer
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 14:39:53 +02:00
df3bb71357 Serve static files with Nginx only in production to make JavaScript development easier
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:58:48 +02:00
2a216fd994 Entries are distinct
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:50:39 +02:00
8dd2619013 Activities are distinct
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:50:21 +02:00
62431a4910 Treasurers can manage activity entries
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:49:16 +02:00
946bc1e497 show that rows are clickable, fix #75 2021-10-01 14:35:29 +02:00
d4896bfd76 Check that club's note is active before creating an activity
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-28 17:03:32 +02:00
23f46cc598 Create transfers when pressing Enter in the amount part
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-28 16:57:23 +02:00
d1a9f21b56 Merge branch 'fix-pretty-money' into 'beta'
Pretty money function is invalid in Javascript: it mays display an additional euro

See merge request bde/nk20!183
2021-09-28 09:36:44 +00:00
d809b2595a Pretty money function is invalid in Javascript: it mays display an additional euro
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-28 11:20:57 +02:00
97803ac983 Merge branch 'beta' into 'master'
Le [Pot] c'est demain

See merge request bde/nk20!182
2021-09-27 14:52:09 +00:00
b951c4aa05 Merge branch 'fix-pot' into 'beta'
Entrées activités

See merge request bde/nk20!181
2021-09-27 14:37:10 +00:00
69b3d2ac9c [activity] Fix button shortcut to entries page
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 14:51:17 +02:00
f29054558a Fix note render with formattable aliases
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 14:30:47 +02:00
11dd8adbb7 Merge branch 'wei' into 'master'
[WEI] Algo de répartition

Closes #97 et #98

See merge request bde/nk20!180
2021-09-27 12:28:03 +00:00
d437f2bdbd Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 13:59:43 +02:00
ac8453b04c [WEI] Reset cache after running algorithm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 13:56:10 +02:00
6b4d18f4b3 fix #97 2021-09-26 23:03:25 +02:00
668cfa71a7 fix #98 2021-09-26 23:02:31 +02:00
161db0b00b [WEI] Fix quotas
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 23:48:03 +02:00
8638c16b34 [WEI] New score function that takes in account scores given by other buses
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 22:15:45 +02:00
9583cec3ff [WEI] Fix quotas
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 21:10:23 +02:00
1ef25924a0 [WEI] Display status bar with tqdm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 20:46:34 +02:00
e89383e3f4 [WEI] Start repartition by non-male people
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 20:06:34 +02:00
79a116d9c6 [WEI] Cache optimization
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 20:05:20 +02:00
aa75ce5c7a [WEI] Don't manage hardcoded people in repartition algorithm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 15:37:18 +02:00
a3a9dfc812 [Treasury] Don't add non-existing transactions to sogé-credits (eg. when membership is free)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 11:00:10 +02:00
76531595ad 80 € for people that opened an account to Société générale and don't go to the WEI
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 10:58:23 +02:00
a0b920ac94 Don't check permission to edit credit transaction test while deleting a SogéCredit
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-15 12:40:21 +02:00
ab2e580e68 Update banner text for more precision
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-15 12:14:57 +02:00
0234f19a33 [WEI] Automatically indicate a soge credit if already created
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-14 13:45:01 +02:00
1a4b7c83e8 [WEI] Fix critical security issue
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-13 23:37:27 +02:00
4c17e2a92b Fix wrong banner message
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-13 23:29:51 +02:00
e68afc7d0a [WEI] Fix redirect link
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-13 21:06:44 +02:00
c6e3b54f94 Use longtable for better tables for WEI
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-13 20:27:57 +02:00
7e6a14296a Merge branch 'beta' into 'master'
Magnifique UI pour le WEI

See merge request bde/nk20!179
2021-09-13 18:06:03 +00:00
780f78b385 Merge branch 'wei' into 'beta'
[WEI] Belle UI pour attribuer les 1A dans les bus

See merge request bde/nk20!178
2021-09-13 17:50:34 +00:00
4e3c32eb5e Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-13 19:28:15 +02:00
ef118c2445 [WEI] Avoid errors if the survey is not ended
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-13 19:24:53 +02:00
600ba15faa [WEI] Display suggested 1A number in a bus in repartition view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-13 19:04:11 +02:00
944bb127e2 [WEI] New UI is working
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-12 22:29:57 +02:00
f6d042c998 [WEI] Attribute bus to people that paid their registration
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-12 20:10:50 +02:00
bb9a0a2593 [WEI] UI to attribute buses for 1A
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-12 19:49:22 +02:00
61feac13c7 [WEI] Add page that display information about the algorithm result
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-11 19:16:34 +02:00
81e708a7e3 [WEI] Fix registration update
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-11 14:20:38 +02:00
3532846c87 [WEI] Validate WEI memberships of first year members before the repartition algorithm to debit notes
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-10 22:09:47 +02:00
49551e88f8 Fix default promotion year
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 19:51:57 +02:00
db936bf75a Avoid anonymous users to access to the WEI registration form
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 17:52:52 +02:00
5828a20383 Merge branch 'beta' into 'master'
Corrections de bugs

See merge request bde/nk20!177
2021-09-09 12:00:01 +00:00
cea3138daf Merge branch 'wei' into 'beta'
Corrections de bugs

See merge request bde/nk20!176
2021-09-09 11:43:34 +00:00
fb98d9cd8b Fix one more error in alias autocompletion
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 10:53:40 +02:00
0dd3da5c01 Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 10:45:36 +02:00
af4be98b5b Fix consumer search with non-regex values (only for consumers, not for all search fields in API)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 10:41:57 +02:00
be6059eba6 [WEI] Fix tests
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 10:20:57 +02:00
5793b83de7 [WEI] Fix error when validating sometimes a membership
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 09:27:15 +02:00
2c02c747f4 [WEI] Fix errors when a user go to the WEI registration form while it is already registered
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 09:23:12 +02:00
a78f3b7caa [WEI] Fix broken tests
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 09:16:08 +02:00
1ee40cb94e Fix chemistry department (warning: this may break the choices from members of the department)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 09:10:05 +02:00
bd035744a4 Don't create WEI registrations for unvalidated users
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-09 08:56:21 +02:00
7edd622755 BDE members can now use their note balance for personal transactions
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-08 18:35:36 +02:00
8fd5b6ee01 Fix safe summary for old passwords hashes from NK15 in Django Admin
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-08 17:07:07 +02:00
03411ac9bd Don't check permissions in a script
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-08 16:59:44 +02:00
d965732b65 Support multiple addresses for IP-based connection (useful when using IPv4/IPv6 and for ENS -> Crans transition)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-08 14:52:39 +02:00
048266ed61 [WEI] Fix unvalidated registrations table
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-07 22:09:00 +02:00
b27341009e [WEI] Update validation buttons for 1A
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-07 15:11:15 +02:00
da1e15c5e6 Update Sogé credit amount when a transaction is added if the credit was already validated
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-07 13:04:09 +02:00
4b03a78ad6 Fix password change form from unauthenticated users
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-07 12:57:03 +02:00
fb6e3c3de0 If connected and if we have the right, directly redirect to the validation page when registering someone
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-07 10:56:50 +02:00
391f3bde8f Fix permission to see note balance when we can't see profile detail (e.g. for note account)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-06 11:56:56 +02:00
ad04e45992 PC Kfet can create and update Sogé credits (but not see them)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-06 11:43:39 +02:00
4e1ba1447a Add option to add a posteriori a Sogé credit
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-06 00:47:11 +02:00
b646f549d6 When creating a Sogé credit, serch existing recent memberships and register them
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 21:24:16 +02:00
ba9ef0371a [WEI] Run algorithm only on valid surveys
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 20:36:17 +02:00
881cd88f48 [WEI] Fix permission check for information json
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 20:10:21 +02:00
b4ed354b73 Merge branch 'wei' into 'master'
Amélirations questionnaire WEI

See merge request bde/nk20!175
2021-09-05 17:32:57 +00:00
e5051ab018 Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 19:32:34 +02:00
bb69627ac5 Remove debug code
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 18:57:07 +02:00
ffaa020310 Fix WEI registration in dev mode
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 18:52:57 +02:00
6d2b7054e2 [WEI] Optimizations in survey load
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 18:49:34 +02:00
d888d5863a [WEI] For each bus, choose a random word which score is higher than the mid score
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 18:39:03 +02:00
dbc7b3444b [WEI] Add script to import bus scores
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 18:23:55 +02:00
f25eb1d2c5 [WEI] Fix some issues
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 17:30:59 +02:00
a2a749e1ca [WEI] Fix permission check to register new accounts to users
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-05 17:15:19 +02:00
5bf6a5501d [WEI] Fix test for 1A registration
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-04 13:03:38 +02:00
9523b5f05f [WEI] Choose one word per bus in the survey
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-04 12:37:29 +02:00
5eb3ffca66 Merge branch 'beta' into 'master'
OAuth2, tests WEI

See merge request bde/nk20!174
2021-09-02 20:49:58 +00:00
9930c48253 Merge branch 'oauth2' into 'beta'
Implement OAuth2 scopes based on permissions

See merge request bde/nk20!170
2021-09-02 19:18:43 +00:00
d902e63a0c Allow search aliases per exact name
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:46 +02:00
48b0bade51 Indicate what scopes are used
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:46 +02:00
f75dbc4525 OAuth2 implementation documentation
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:45 +02:00
fbf64db16e Simple test to check permissions with the new OAuth2 implementation
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:45 +02:00
a3fd8ba063 Bad paste in comment
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:45 +02:00
9b26207515 Rework templates for OAuth2
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:43 +02:00
7ea36a5415 [oauth2] Add view to generate authorization link per application with given scopes
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:33 +02:00
898f6d52bf Better templates for OAuth2 authentication
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:59:20 +02:00
8be16e7b58 Permissions support fully OAuth2 scopes
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:58:05 +02:00
ea092803d7 Check permissions per request instead of per user
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:58:05 +02:00
5e9f36ef1a Store current request rather than user/session/ip
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:58:04 +02:00
b4d87bc6b5 Fix import
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:58:04 +02:00
dd639d829e Implement OAuth2 scopes based on permissions
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 20:58:04 +02:00
7b809ff3a6 Merge branch 'wei' into 'beta'
[WEI] Correction de l'algorithme et tests unitaires

See merge request bde/nk20!173
2021-09-02 18:53:21 +00:00
d36edfc063 Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 13:44:18 +02:00
cf87da096f No more offer 80 € to new members since there is a WEI
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 13:39:17 +02:00
e452b7acbf [WEI] Allow a tolerance of 25 %
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 09:53:27 +02:00
74ab4df9fe [WEI] Extreme test with full buses and quality constraints
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-02 01:36:37 +02:00
451851c955 [WEI] Add a small test for the WEI algorithm with a few people
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-01 22:53:28 +02:00
789ca149af Merge branch 'beta' into 'master'
WEI, diverses améliorations

See merge request bde/nk20!172
2021-08-29 13:22:04 +00:00
7d3f1930b8 Merge branch 'wei' into 'beta'
Améliorations WEI

See merge request bde/nk20!171
2021-08-29 13:03:02 +00:00
e8f4ca1e09 Fix note account
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-29 14:40:55 +02:00
733f145be3 BDE members can now use they note even if they are not in the Kfet club
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-29 14:39:36 +02:00
48c37353ea [WEI] Fix pipeline before the good unit tests for WEI algorithm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-29 14:38:11 +02:00
8056dc096d [WEI] Old members can create WEI registrations to renew their membership easily
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-29 14:33:17 +02:00
6d5b69cd26 Fix verification of parent club membership
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-29 14:17:09 +02:00
a7bdffd71a [WEI] Change color of validation button of WEI registrations
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-29 14:10:52 +02:00
0887e4bbde [WEI] Fix some tests, without considering WEI algorithm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-27 13:15:28 +02:00
199f4ca1f2 [WEI] First implementation of algorithm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-27 10:44:38 +02:00
08ba0b263a Merge branch 'beta' into 'master'
changement couleur final (j'espère)

See merge request bde/nk20!166
2021-05-22 14:09:51 +00:00
4583958f50 Merge branch 'beta' into 'master'
Changement de couleurs

See merge request bde/nk20!165
2021-05-22 09:56:55 +00:00
bab394908d Merge branch 'beta' into 'master'
Bugs mineurs, documentation

See merge request bde/nk20!162
2021-04-23 19:32:54 +00:00
109 changed files with 5716 additions and 1842 deletions

1
.gitignore vendored
View File

@ -47,6 +47,7 @@ backups/
env/ env/
venv/ venv/
db.sqlite3 db.sqlite3
shell.nix
# ansibles customs host # ansibles customs host
ansible/host_vars/*.yaml ansible/host_vars/*.yaml

View File

@ -1,8 +1,8 @@
# NoteKfet 2020 # NoteKfet 2020
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt) [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt)
[![pipeline status](https://gitlab.crans.org/bde/nk20/badges/master/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/master) [![pipeline status](https://gitlab.crans.org/bde/nk20/badges/main/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master) [![coverage report](https://gitlab.crans.org/bde/nk20/badges/main/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
## Table des matières ## Table des matières

View File

@ -7,7 +7,7 @@
prompt: "Password of the database (leave it blank to skip database init)" prompt: "Password of the database (leave it blank to skip database init)"
private: yes private: yes
vars: vars:
mirror: mirror.crans.org mirror: eclats.crans.org
roles: roles:
- 1-apt-basic - 1-apt-basic
- 2-nk20 - 2-nk20

View File

@ -1,6 +0,0 @@
---
note:
server_name: note-beta.crans.org
git_branch: beta
cron_enabled: false
email: notekfet2020@lists.crans.org

View File

@ -2,5 +2,6 @@
note: note:
server_name: note-dev.crans.org server_name: note-dev.crans.org
git_branch: beta git_branch: beta
serve_static: false
cron_enabled: false cron_enabled: false
email: notekfet2020@lists.crans.org email: notekfet2020@lists.crans.org

View File

@ -1,6 +1,7 @@
--- ---
note: note:
server_name: note.crans.org server_name: note.crans.org
git_branch: master git_branch: main
serve_static: true
cron_enabled: true cron_enabled: true
email: notekfet2020@lists.crans.org email: notekfet2020@lists.crans.org

View File

@ -1,6 +1,5 @@
[dev] [dev]
bde-note-dev.adh.crans.org bde-note-dev.adh.crans.org
bde-nk20-beta.adh.crans.org
[prod] [prod]
bde-note.adh.crans.org bde-note.adh.crans.org

View File

@ -1,14 +1,15 @@
--- ---
- name: Add buster-backports to apt sources - name: Add buster-backports to apt sources if needed
apt_repository: apt_repository:
repo: deb http://{{ mirror }}/debian buster-backports main repo: deb http://{{ mirror }}/debian buster-backports main
state: present state: present
when: ansible_facts['distribution'] == "Debian" when:
- ansible_distribution == "Debian"
- ansible_distribution_major_version | int == 10
- name: Install note_kfet APT dependencies - name: Install note_kfet APT dependencies
apt: apt:
update_cache: true update_cache: true
default_release: "{{ 'buster-backports' if ansible_facts['distribution'] == 'Debian' }}"
install_recommends: false install_recommends: false
name: name:
# Common tools # Common tools

View File

@ -41,6 +41,7 @@ server {
# max upload size # max upload size
client_max_body_size 75M; # adjust to taste client_max_body_size 75M; # adjust to taste
{% if note.serve_static %}
# Django media # Django media
location /media { location /media {
alias /var/www/note_kfet/media; # your Django project's media files - amend as required alias /var/www/note_kfet/media; # your Django project's media files - amend as required
@ -50,6 +51,7 @@ server {
alias /var/www/note_kfet/static; # your Django project's static files - amend as required alias /var/www/note_kfet/static; # your Django project's static files - amend as required
} }
{% endif %}
location /doc { location /doc {
alias /var/www/documentation; # The documentation of the project alias /var/www/documentation; # The documentation of the project
} }

View File

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
from member.models import Club from member.models import Club
from note.models import Note, NoteUser from note.models import Note, NoteUser
from note_kfet.inputs import Autocomplete, DateTimePickerInput from note_kfet.inputs import Autocomplete, DateTimePickerInput
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import Activity, Guest from .models import Activity, Guest
@ -24,10 +24,16 @@ class ActivityForm(forms.ModelForm):
self.fields["attendees_club"].initial = Club.objects.get(name="Kfet") self.fields["attendees_club"].initial = Club.objects.get(name="Kfet")
self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet" self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet"
clubs = list(Club.objects.filter(PermissionBackend clubs = list(Club.objects.filter(PermissionBackend
.filter_queryset(get_current_authenticated_user(), Club, "view")).all()) .filter_queryset(get_current_request(), Club, "view")).all())
shuffle(clubs) shuffle(clubs)
self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..." self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
def clean_organizer(self):
organizer = self.cleaned_data['organizer']
if not organizer.note.is_active:
self.add_error('organiser', _('The note of this club is inactive.'))
return organizer
def clean_date_end(self): def clean_date_end(self):
date_end = self.cleaned_data["date_end"] date_end = self.cleaned_data["date_end"]
date_start = self.cleaned_data["date_start"] date_start = self.cleaned_data["date_start"]

View File

@ -1,7 +1,9 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.utils import timezone from django.utils import timezone
from django.utils.html import format_html from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables
from django_tables2 import A from django_tables2 import A
@ -52,7 +54,7 @@ class GuestTable(tables.Table):
def render_entry(self, record): def render_entry(self, record):
if record.has_entry: if record.has_entry:
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, ))) return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
return format_html('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> ' return mark_safe('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> '
'{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize())) '{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize()))
@ -91,7 +93,7 @@ class EntryTable(tables.Table):
if hasattr(record, 'username'): if hasattr(record, 'username'):
username = record.username username = record.username
if username != value: if username != value:
return format_html(value + " <em>aka.</em> " + username) return mark_safe(escape(value) + " <em>aka.</em> " + escape(username))
return value return value
def render_balance(self, value): def render_balance(self, value):

View File

@ -63,7 +63,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
refreshBalance(); refreshBalance();
} }
alias_obj.keyup(reloadTable); alias_obj.keyup(function(event) {
let code = event.originalEvent.keyCode
if (65 <= code <= 122 || code === 13) {
debounce(reloadTable)()
}
});
$(document).ready(init); $(document).ready(init);

View File

@ -66,21 +66,19 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
ordering = ('-date_start',) ordering = ('-date_start',)
extra_context = {"title": _("Activities")} extra_context = {"title": _("Activities")}
def get_queryset(self): def get_queryset(self, **kwargs):
return super().get_queryset().distinct() return super().get_queryset(**kwargs).distinct()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
context['upcoming'] = ActivityTable( context['upcoming'] = ActivityTable(
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")),
prefix='upcoming-', prefix='upcoming-',
) )
started_activities = Activity.objects\ started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.filter(open=True, valid=True).all()
context["started_activities"] = started_activities context["started_activities"] = started_activities
return context return context
@ -98,7 +96,7 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data() context = super().get_context_data()
table = GuestTable(data=Guest.objects.filter(activity=self.object) table = GuestTable(data=Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) .filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
context["guests"] = table context["guests"] = table
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
@ -144,15 +142,15 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.get(pk=self.kwargs["pk"]) .filter(pk=self.kwargs["pk"]).first()
form.fields["inviter"].initial = self.request.user.note form.fields["inviter"].initial = self.request.user.note
return form return form
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
form.instance.activity = Activity.objects\ form.instance.activity = Activity.objects\
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"])
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
@ -170,10 +168,13 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
it is closed or doesn't manage entries. it is closed or doesn't manage entries.
""" """
if not self.request.user.is_authenticated:
return self.handle_no_permission()
activity = Activity.objects.get(pk=self.kwargs["pk"]) activity = Activity.objects.get(pk=self.kwargs["pk"])
sample_entry = Entry(activity=activity, note=self.request.user.note) sample_entry = Entry(activity=activity, note=self.request.user.note)
if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): if not PermissionBackend.check_perm(self.request, "activity.add_entry", sample_entry):
raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
if not activity.activity_type.manage_entries: if not activity.activity_type.manage_entries:
@ -191,8 +192,8 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
guest_qs = Guest.objects\ guest_qs = Guest.objects\
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
.filter(activity=activity)\ .filter(activity=activity)\
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ .filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
.order_by('last_name', 'first_name').distinct() .order_by('last_name', 'first_name')
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
@ -206,7 +207,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
) )
else: else:
guest_qs = guest_qs.none() guest_qs = guest_qs.none()
return guest_qs return guest_qs.distinct()
def get_invited_note(self, activity): def get_invited_note(self, activity):
""" """
@ -230,7 +231,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
) )
# Filter with permission backend # Filter with permission backend
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view"))
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
@ -256,7 +257,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"]) .distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity context["activity"] = activity
@ -281,9 +282,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
activities_open = Activity.objects.filter(open=True).filter( activities_open = Activity.objects.filter(open=True).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
context["activities_open"] = [a for a in activities_open context["activities_open"] = [a for a in activities_open
if PermissionBackend.check_perm(self.request.user, if PermissionBackend.check_perm(self.request,
"activity.add_entry", "activity.add_entry",
Entry(activity=a, note=self.request.user.note,))] Entry(activity=a, note=self.request.user.note,))]

View File

@ -7,8 +7,11 @@ from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from member.api.serializers import ProfileSerializer, MembershipSerializer from member.api.serializers import ProfileSerializer, MembershipSerializer
from member.models import Membership
from note.api.serializers import NoteSerializer from note.api.serializers import NoteSerializer
from note.models import Alias from note.models import Alias
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@ -45,18 +48,30 @@ class OAuthSerializer(serializers.ModelSerializer):
""" """
normalized_name = serializers.SerializerMethodField() normalized_name = serializers.SerializerMethodField()
profile = ProfileSerializer() profile = serializers.SerializerMethodField()
note = NoteSerializer() note = serializers.SerializerMethodField()
memberships = serializers.SerializerMethodField() memberships = serializers.SerializerMethodField()
def get_normalized_name(self, obj): def get_normalized_name(self, obj):
return Alias.normalize(obj.username) return Alias.normalize(obj.username)
def get_profile(self, obj):
# Display the profile of the user only if we have rights to see it.
return ProfileSerializer().to_representation(obj.profile) \
if PermissionBackend.check_perm(get_current_request(), 'member.view_profile', obj.profile) else None
def get_note(self, obj):
# Display the note of the user only if we have rights to see it.
return NoteSerializer().to_representation(obj.note) \
if PermissionBackend.check_perm(get_current_request(), 'note.view_note', obj.note) else None
def get_memberships(self, obj): def get_memberships(self, obj):
# Display only memberships that we are allowed to see.
return serializers.ListSerializer(child=MembershipSerializer()).to_representation( return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())) obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())
.filter(PermissionBackend.filter_queryset(get_current_request(), Membership, 'view')))
class Meta: class Meta:
model = User model = User

View File

@ -9,7 +9,6 @@ from django.contrib.auth.models import User
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from note_kfet.middlewares import get_current_session
from note.models import Alias from note.models import Alias
from .serializers import UserSerializer, ContentTypeSerializer from .serializers import UserSerializer, ContentTypeSerializer
@ -25,9 +24,7 @@ class ReadProtectedModelViewSet(ModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self): def get_queryset(self):
user = self.request.user return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet): class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
@ -40,9 +37,7 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self): def get_queryset(self):
user = self.request.user return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class UserViewSet(ReadProtectedModelViewSet): class UserViewSet(ReadProtectedModelViewSet):

View File

@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from note.models import NoteUser, Alias from note.models import NoteUser, Alias
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip from note_kfet.middlewares import get_current_request
from .models import Changelog from .models import Changelog
@ -57,9 +57,9 @@ def save_object(sender, instance, **kwargs):
previous = instance._previous previous = instance._previous
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip() request = get_current_request()
if user is None: if request is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
@ -71,9 +71,23 @@ def save_object(sender, instance, **kwargs):
# else: # else:
if note.exists(): if note.exists():
user = note.get().user user = note.get().user
else:
user = None
else:
user = request.user
if 'HTTP_X_REAL_IP' in request.META:
ip = request.META.get('HTTP_X_REAL_IP')
elif 'HTTP_X_FORWARDED_FOR' in request.META:
ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
else:
ip = request.META.get('REMOTE_ADDR')
if not user.is_authenticated:
# For registration and OAuth2 purposes
user = None
# noinspection PyProtectedMember # noinspection PyProtectedMember
if user is not None and instance._meta.label_lower == "auth.user" and previous: if request is not None and instance._meta.label_lower == "auth.user" and previous:
# On n'enregistre pas les connexions # On n'enregistre pas les connexions
if instance.last_login != previous.last_login: if instance.last_login != previous.last_login:
return return
@ -121,9 +135,9 @@ def delete_object(sender, instance, **kwargs):
return return
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip() request = get_current_request()
if user is None: if request is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
@ -135,6 +149,20 @@ def delete_object(sender, instance, **kwargs):
# else: # else:
if note.exists(): if note.exists():
user = note.get().user user = note.get().user
else:
user = None
else:
user = request.user
if 'HTTP_X_REAL_IP' in request.META:
ip = request.META.get('HTTP_X_REAL_IP')
elif 'HTTP_X_FORWARDED_FOR' in request.META:
ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
else:
ip = request.META.get('REMOTE_ADDR')
if not user.is_authenticated:
# For registration and OAuth2 purposes
user = None
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
class CustomSerializer(ModelSerializer): class CustomSerializer(ModelSerializer):

View File

@ -2,11 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import hashlib import hashlib
from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import PBKDF2PasswordHasher from django.contrib.auth.hashers import PBKDF2PasswordHasher, mask_hash
from django.utils.crypto import constant_time_compare from django.utils.crypto import constant_time_compare
from note_kfet.middlewares import get_current_authenticated_user, get_current_session from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
class CustomNK15Hasher(PBKDF2PasswordHasher): class CustomNK15Hasher(PBKDF2PasswordHasher):
@ -24,16 +26,22 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
def must_update(self, encoded): def must_update(self, encoded):
if settings.DEBUG: if settings.DEBUG:
current_user = get_current_authenticated_user() # Small hack to let superusers to impersonate people.
# Don't change their password.
request = get_current_request()
current_user = request.user
if current_user is not None and current_user.is_superuser: if current_user is not None and current_user.is_superuser:
return False return False
return True return True
def verify(self, password, encoded): def verify(self, password, encoded):
if settings.DEBUG: if settings.DEBUG:
current_user = get_current_authenticated_user() # Small hack to let superusers to impersonate people.
# If a superuser is already connected, let him/her log in as another person.
request = get_current_request()
current_user = request.user
if current_user is not None and current_user.is_superuser\ if current_user is not None and current_user.is_superuser\
and get_current_session().get("permission_mask", -1) >= 42: and request.session.get("permission_mask", -1) >= 42:
return True return True
if '|' in encoded: if '|' in encoded:
@ -41,6 +49,18 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
return constant_time_compare(hashlib.sha256((salt + password).encode("utf-8")).hexdigest(), db_hashed_pass) return constant_time_compare(hashlib.sha256((salt + password).encode("utf-8")).hexdigest(), db_hashed_pass)
return super().verify(password, encoded) return super().verify(password, encoded)
def safe_summary(self, encoded):
# Displayed information in Django Admin.
if '|' in encoded:
salt, db_hashed_pass = encoded.split('$')[2].split('|')
return OrderedDict([
(_('algorithm'), 'custom_nk15'),
(_('iterations'), '1'),
(_('salt'), mask_hash(salt)),
(_('hash'), mask_hash(db_hashed_pass)),
])
return super().safe_summary(encoded)
class DebugSuperuserBackdoor(PBKDF2PasswordHasher): class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
""" """
@ -51,8 +71,11 @@ class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
def verify(self, password, encoded): def verify(self, password, encoded):
if settings.DEBUG: if settings.DEBUG:
current_user = get_current_authenticated_user() # Small hack to let superusers to impersonate people.
# If a superuser is already connected, let him/her log in as another person.
request = get_current_request()
current_user = request.user
if current_user is not None and current_user.is_superuser\ if current_user is not None and current_user.is_superuser\
and get_current_session().get("permission_mask", -1) >= 42: and request.session.get("permission_mask", -1) >= 42:
return True return True
return super().verify(password, encoded) return super().verify(password, encoded)

View File

@ -19,8 +19,8 @@ def create_bde_and_kfet(apps, schema_editor):
membership_fee_paid=500, membership_fee_paid=500,
membership_fee_unpaid=500, membership_fee_unpaid=500,
membership_duration=396, membership_duration=396,
membership_start="2020-08-01", membership_start="2021-08-01",
membership_end="2021-09-30", membership_end="2022-09-30",
) )
Club.objects.get_or_create( Club.objects.get_or_create(
id=2, id=2,
@ -31,8 +31,8 @@ def create_bde_and_kfet(apps, schema_editor):
membership_fee_paid=3500, membership_fee_paid=3500,
membership_fee_unpaid=3500, membership_fee_unpaid=3500,
membership_duration=396, membership_duration=396,
membership_start="2020-08-01", membership_start="2021-08-01",
membership_end="2021-09-30", membership_end="2022-09-30",
) )
NoteClub.objects.get_or_create( NoteClub.objects.get_or_create(

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.24 on 2021-10-05 13:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0007_auto_20210313_1235'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='department',
field=models.CharField(choices=[('A0', 'Informatics (A0)'), ('A1', 'Mathematics (A1)'), ('A2', 'Physics (A2)'), ("A'2", "Applied physics (A'2)"), ("A''2", "Chemistry (A''2)"), ('A3', 'Biology (A3)'), ('B1234', 'SAPHIRE (B1234)'), ('B1', 'Mechanics (B1)'), ('B2', 'Civil engineering (B2)'), ('B3', 'Mechanical engineering (B3)'), ('B4', 'EEA (B4)'), ('C', 'Design (C)'), ('D2', 'Economy-management (D2)'), ('D3', 'Social sciences (D3)'), ('E', 'English (E)'), ('EXT', 'External (EXT)')], max_length=8, verbose_name='department'),
),
]

View File

@ -57,7 +57,7 @@ class Profile(models.Model):
('A1', _("Mathematics (A1)")), ('A1', _("Mathematics (A1)")),
('A2', _("Physics (A2)")), ('A2', _("Physics (A2)")),
("A'2", _("Applied physics (A'2)")), ("A'2", _("Applied physics (A'2)")),
('A''2', _("Chemistry (A''2)")), ("A''2", _("Chemistry (A''2)")),
('A3', _("Biology (A3)")), ('A3', _("Biology (A3)")),
('B1234', _("SAPHIRE (B1234)")), ('B1234', _("SAPHIRE (B1234)")),
('B1', _("Mechanics (B1)")), ('B1', _("Mechanics (B1)")),
@ -74,7 +74,7 @@ class Profile(models.Model):
promotion = models.PositiveSmallIntegerField( promotion = models.PositiveSmallIntegerField(
null=True, null=True,
default=datetime.date.today().year, default=datetime.date.today().year if datetime.date.today().month >= 8 else datetime.date.today().year - 1,
verbose_name=_("promotion"), verbose_name=_("promotion"),
help_text=_("Year of entry to the school (None if not ENS student)"), help_text=_("Year of entry to the school (None if not ENS student)"),
) )
@ -258,14 +258,16 @@ class Club(models.Model):
This function is called each time the club detail view is displayed. This function is called each time the club detail view is displayed.
Update the year of the membership dates. Update the year of the membership dates.
""" """
if not self.membership_start: if not self.membership_start or not self.membership_end:
return return
today = datetime.date.today() today = datetime.date.today()
if (today - self.membership_start).days >= 365: if (today - self.membership_start).days >= 365:
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1, self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day) self.membership_start.month, self.membership_start.day)
if self.membership_end:
self.membership_end = datetime.date(self.membership_end.year + 1, self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day) self.membership_end.month, self.membership_end.day)
self._force_save = True self._force_save = True
@ -413,6 +415,12 @@ class Membership(models.Model):
""" """
Calculate fee and end date before saving the membership and creating the transaction if needed. Calculate fee and end date before saving the membership and creating the transaction if needed.
""" """
# Ensure that club membership dates are valid
old_membership_start = self.club.membership_start
self.club.update_membership_dates()
if self.club.membership_start != old_membership_start:
self.club.save()
created = not self.pk created = not self.pk
if not created: if not created:
for role in self.roles.all(): for role in self.roles.all():

View File

@ -0,0 +1,53 @@
/**
* On form submit, create a new friendship
*/
function create_trust (e) {
// Do not submit HTML form
e.preventDefault()
// Get data and send to API
const formData = new FormData(e.target)
$.getJSON('/api/note/alias/'+formData.get('trusted') + '/',
function (trusted_alias) {
if ((trusted_alias.note == formData.get('trusting')))
{
addMsg(gettext("You can't add yourself as a friend"), "danger")
return
}
$.post('/api/note/trust/', {
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
trusting: formData.get('trusting'),
trusted: trusted_alias.note
}).done(function () {
// Reload table
$('#trust_table').load(location.pathname + ' #trust_table')
addMsg(gettext('Friendship successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the alias
* @param button_id:Integer Alias id to remove
*/
function delete_button (button_id) {
$.ajax({
url: '/api/note/trust/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
addMsg(gettext('Friendship successfully deleted'), 'success')
$('#trust_table').load(location.pathname + ' #trust_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
$(document).ready(function () {
// Attach event
document.getElementById('form_trust').addEventListener('submit', create_trust)
})

View File

@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.html import format_html from django.utils.html import format_html
from note.templatetags.pretty_money import pretty_money from note.templatetags.pretty_money import pretty_money
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import Club, Membership from .models import Club, Membership
@ -31,7 +31,8 @@ class ClubTable(tables.Table):
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'id': lambda record: "row-" + str(record.pk), 'id': lambda record: "row-" + str(record.pk),
'data-href': lambda record: record.pk 'data-href': lambda record: record.pk,
'style': 'cursor:pointer',
} }
@ -51,19 +52,19 @@ class UserTable(tables.Table):
def render_email(self, record, value): def render_email(self, record, value):
# Replace the email by a dash if the user can't see the profile detail # Replace the email by a dash if the user can't see the profile detail
# Replace also the URL # Replace also the URL
if not PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_profile", record.profile): if not PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile):
value = "" value = ""
record.email = value record.email = value
return value return value
def render_section(self, record, value): def render_section(self, record, value):
return value \ return value \
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_profile", record.profile) \ if PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile) \
else "" else ""
def render_balance(self, record, value): def render_balance(self, record, value):
return pretty_money(value)\ return pretty_money(value)\
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", record.note) else "" if PermissionBackend.check_perm(get_current_request(), "note.view_note", record.note) else ""
class Meta: class Meta:
attrs = { attrs = {
@ -74,7 +75,8 @@ class UserTable(tables.Table):
model = User model = User
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'data-href': lambda record: record.pk 'data-href': lambda record: record.pk,
'style': 'cursor:pointer',
} }
@ -93,7 +95,7 @@ class MembershipTable(tables.Table):
def render_user(self, value): def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.username s = value.username
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
@ -102,7 +104,7 @@ class MembershipTable(tables.Table):
def render_club(self, value): def render_club(self, value):
# If the user has the right, link the displayed club with the page of its detail. # If the user has the right, link the displayed club with the page of its detail.
s = value.name s = value.name
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value): if PermissionBackend.check_perm(get_current_request(), "member.view_club", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
@ -118,7 +120,7 @@ class MembershipTable(tables.Table):
club=record.club, club=record.club,
user=record.user, user=record.user,
date_start__gte=record.club.membership_start, date_start__gte=record.club.membership_start,
date_end__lte=record.club.membership_end, date_end__lte=record.club.membership_end or date(9999, 12, 31),
).exists(): # If the renew is not yet performed ).exists(): # If the renew is not yet performed
empty_membership = Membership( empty_membership = Membership(
club=record.club, club=record.club,
@ -127,7 +129,7 @@ class MembershipTable(tables.Table):
date_end=date.today(), date_end=date.today(),
fee=0, fee=0,
) )
if PermissionBackend.check_perm(get_current_authenticated_user(), if PermissionBackend.check_perm(get_current_request(),
"member.add_membership", empty_membership): # If the user has right "member.add_membership", empty_membership): # If the user has right
renew_url = reverse_lazy('member:club_renew_membership', renew_url = reverse_lazy('member:club_renew_membership',
kwargs={"pk": record.pk}) kwargs={"pk": record.pk})
@ -142,7 +144,7 @@ class MembershipTable(tables.Table):
# If the user has the right to manage the roles, display the link to manage them # If the user has the right to manage the roles, display the link to manage them
roles = record.roles.all() roles = record.roles.all()
s = ", ".join(str(role) for role in roles) s = ", ".join(str(role) for role in roles)
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record): if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record):
s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk})) s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
+ "'>" + s + "</a>") + "'>" + s + "</a>")
return s return s
@ -165,7 +167,7 @@ class ClubManagerTable(tables.Table):
def render_user(self, value): def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.username s = value.username
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)

View File

@ -25,6 +25,14 @@
</a> </a>
</dd> </dd>
<dt class="col-xl-6">{% trans 'friendships'|capfirst %}</dt>
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:user_trust' user_object.pk %}">
<i class="fa fa-edit"></i>
{% trans 'Manage friendships' %} ({{ user_object.note.trusting.all|length }})
</a>
</dd>
{% if "member.view_profile"|has_perm:user_object.profile %} {% if "member.view_profile"|has_perm:user_object.profile %}
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.section }}</dd> <dd class="col-xl-6">{{ user_object.profile.section }}</dd>
@ -39,13 +47,13 @@
<dt class="col-xl-6">{% trans 'address'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.address }}</dd> <dd class="col-xl-6">{{ user_object.profile.address }}</dd>
{% if user_object.note and "note.view_note"|has_perm:user_object.note %}
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
<dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd> <dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd>
{% endif %} {% endif %}
{% if user_object.note and "note.view_note"|has_perm:user_object.note %}
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
{% endif %} {% endif %}
</dl> </dl>

View File

@ -5,17 +5,26 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<div class="alert alert-info"> <div class="row mt-4">
<div class="col-xl-6">
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Token authentication" %}</h3>
</div>
<div class="card-body">
<div class="alert alert-info">
<h4>À quoi sert un jeton d'authentification ?</h4> <h4>À quoi sert un jeton d'authentification ?</h4>
Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a>.<br /> Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a> via votre propre compte
depuis un client externe.<br />
Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token &lt;TOKEN&gt;</code> Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token &lt;TOKEN&gt;</code>
pour pouvoir vous identifier.<br /><br /> pour pouvoir vous identifier.<br /><br />
Une documentation de l'API arrivera ultérieurement. La documentation de l'API est disponible ici :
</div> <a href="/doc/api/">{{ request.scheme }}://{{ request.get_host }}/doc/api/</a>.
</div>
<div class="alert alert-info"> <div class="alert alert-info">
<strong>{%trans 'Token' %} :</strong> <strong>{%trans 'Token' %} :</strong>
{% if 'show' in request.GET %} {% if 'show' in request.GET %}
{{ token.key }} (<a href="?">cacher</a>) {{ token.key }} (<a href="?">cacher</a>)
@ -24,13 +33,70 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} {% endif %}
<br /> <br />
<strong>{%trans 'Created' %} :</strong> {{ token.created }} <strong>{%trans 'Created' %} :</strong> {{ token.created }}
</div> </div>
<div class="alert alert-warning"> <div class="alert alert-warning">
<strong>Attention :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton ! <strong>{% trans "Warning" %} :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
</div> </div>
</div>
<a href="?regenerate"> <div class="card-footer text-center">
<a href="?regenerate">
<button class="btn btn-primary">{% trans 'Regenerate token' %}</button> <button class="btn btn-primary">{% trans 'Regenerate token' %}</button>
</a> </a>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card">
<div class="card-header text-center">
<h3>{% trans "OAuth2 authentication" %}</h3>
</div>
<div class="card-header">
<div class="alert alert-info">
<p>
La Note Kfet implémente également le protocole <a href="https://oauth.net/2/">OAuth2</a>, afin de
permettre à des applications tierces d'interagir avec la Note en récoltant des informations
(de connexion par exemple) voir en permettant des modifications à distance, par exemple lorsqu'il
s'agit d'avoir un site marchand sur lequel faire des transactions via la Note Kfet.
</p>
<p>
L'usage de ce protocole est recommandé pour tout usage non personnel, car permet de mieux cibler
les droits dont on a besoin, en restreignant leur usage par jeton généré.
</p>
<p>
La documentation vis-à-vis de l'usage de ce protocole est disponible ici :
<a href="/doc/external_services/oauth2/">{{ request.scheme }}://{{ request.get_host }}/doc/external_services/oauth2/</a>.
</p>
</div>
Liste des URL à communiquer à votre application :
<ul>
<li>
{% trans "Authorization:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}</a>
</li>
<li>
{% trans "Token:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:token' %}</a>
</li>
<li>
{% trans "Revoke Token:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:revoke-token' %}</a>
</li>
<li>
{% trans "Introspect Token:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:introspect' %}</a>
</li>
</ul>
</div>
<div class="card-footer text-center">
<a class="btn btn-primary" href="{% url 'oauth2_provider:list' %}">{% trans "Show my applications" %}</a>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load static django_tables2 i18n %}
{% block profile_content %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Note friendships" %}
</h3>
<div class="card-body">
{% if can_create %}
<form class="input-group" method="POST" id="form_trust">
{% csrf_token %}
<input type="hidden" name="trusting" value="{{ object.note.pk }}">
{%include "autocomplete_model.html" %}
<div class="input-group-append">
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
</div>
</form>
{% endif %}
</div>
{% render_table trusting %}
</div>
<div class="alert alert-warning card">
{% blocktrans trimmed %}
Adding someone as a friend enables them to initiate transactions coming
from your account (while keeping your balance positive). This is
designed to simplify using note kfet transfers to transfer money between
users. The intent is that one person can make all transfers for a group of
friends without needing additional rights among them.
{% endblocktrans %}
</div>
{% endblock %}
{% block extrajavascript %}
<script src="{% static "member/js/trust.js" %}"></script>
<script src="{% static "js/autocomplete_model.js" %}"></script>
{% endblock%}

View File

@ -23,5 +23,6 @@ urlpatterns = [
path('user/<int:pk>/update/', views.UserUpdateView.as_view(), name="user_update_profile"), path('user/<int:pk>/update/', views.UserUpdateView.as_view(), name="user_update_profile"),
path('user/<int:pk>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"), path('user/<int:pk>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"), path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
path('user/<int:pk>/trust', views.ProfileTrustView.as_view(), name="user_trust"),
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'), path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'),
] ]

View File

@ -8,6 +8,7 @@ from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from django.db.models import Q, F from django.db.models import Q, F
from django.shortcuts import redirect from django.shortcuts import redirect
@ -18,10 +19,10 @@ from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django_tables2.views import SingleTableView from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from note.models import Alias, NoteUser from note.models import Alias, NoteClub, NoteUser, Trust
from note.models.transactions import Transaction, SpecialTransaction from note.models.transactions import Transaction, SpecialTransaction
from note.tables import HistoryTable, AliasTable from note.tables import HistoryTable, AliasTable, TrustTable
from note_kfet.middlewares import _set_current_user_and_ip from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.models import Role from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
@ -41,7 +42,8 @@ class CustomLoginView(LoginView):
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
logout(self.request) logout(self.request)
_set_current_user_and_ip(form.get_user(), self.request.session, None) self.request.user = form.get_user()
_set_current_request(self.request)
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
return super().form_valid(form) return super().form_valid(form)
@ -70,7 +72,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.fields['email'].required = True form.fields['email'].required = True
form.fields['email'].help_text = _("This address must be valid.") form.fields['email'].help_text = _("This address must be valid.")
if PermissionBackend.check_perm(self.request.user, "member.change_profile", context['user_object'].profile): if PermissionBackend.check_perm(self.request, "member.change_profile", context['user_object'].profile):
context['profile_form'] = self.profile_form(instance=context['user_object'].profile, context['profile_form'] = self.profile_form(instance=context['user_object'].profile,
data=self.request.POST if self.request.POST else None) data=self.request.POST if self.request.POST else None)
if not self.object.profile.report_frequency: if not self.object.profile.report_frequency:
@ -153,13 +155,13 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
history_list = \ history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\ Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\
.order_by("-created_at")\ .order_by("-created_at")\
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")) .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))
history_table = HistoryTable(history_list, prefix='transaction-') history_table = HistoryTable(history_list, prefix='transaction-')
history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1)) history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1))
context['history_list'] = history_table context['history_list'] = history_table
club_list = Membership.objects.filter(user=user, date_end__gte=date.today() - timedelta(days=15))\ club_list = Membership.objects.filter(user=user, date_end__gte=date.today() - timedelta(days=15))\
.filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))\ .filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\
.order_by("club__name", "-date_start") .order_by("club__name", "-date_start")
# Display only the most recent membership # Display only the most recent membership
club_list = club_list.distinct("club__name")\ club_list = club_list.distinct("club__name")\
@ -173,24 +175,23 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
modified_note = NoteUser.objects.get(pk=user.note.pk) modified_note = NoteUser.objects.get(pk=user.note.pk)
# Don't log these tests # Don't log these tests
modified_note._no_signal = True modified_note._no_signal = True
modified_note.is_active = True modified_note.is_active = False
modified_note.inactivity_reason = 'manual' modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = user.note.is_active and PermissionBackend\ context["can_lock_note"] = user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_noteuser_is_active", .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
modified_note)
old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk) old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk)
modified_note.inactivity_reason = 'forced' modified_note.inactivity_reason = 'forced'
modified_note._force_save = True modified_note._force_save = True
modified_note.save() modified_note.save()
context["can_force_lock"] = user.note.is_active and PermissionBackend\ context["can_force_lock"] = user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_note_is_active", modified_note) .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
old_note._force_save = True old_note._force_save = True
old_note._no_signal = True old_note._no_signal = True
old_note.save() old_note.save()
modified_note.refresh_from_db() modified_note.refresh_from_db()
modified_note.is_active = True modified_note.is_active = True
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\ context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_note_is_active", modified_note) .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
return context return context
@ -237,12 +238,45 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
pre_registered_users = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view"))\ pre_registered_users = User.objects.filter(PermissionBackend.filter_queryset(self.request, User, "view"))\
.filter(profile__registration_valid=False) .filter(profile__registration_valid=False)
context["can_manage_registrations"] = pre_registered_users.exists() context["can_manage_registrations"] = pre_registered_users.exists()
return context return context
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
View and manage user trust relationships
"""
model = User
template_name = 'member/profile_trust.html'
context_object_name = 'user_object'
extra_context = {"title": _("Note friendships")}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["trusting"] = TrustTable(
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
trusting=context["object"].note,
trusted=context["object"].note
))
context["widget"] = {
"name": "trusted",
"attrs": {
"model_pk": ContentType.objects.get_for_model(Alias).pk,
"class": "autocomplete form-control",
"id": "trusted",
"resetable": True,
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
"name_field": "name",
"placeholder": ""
}
}
return context
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
View and manage user aliases. View and manage user aliases.
@ -256,8 +290,9 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
note = context['object'].note note = context['object'].note
context["aliases"] = AliasTable( context["aliases"] = AliasTable(
note.alias.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all()) note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( .order_by('normalized_name').all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note, note=context["object"].note,
name="", name="",
normalized_name="", normalized_name="",
@ -382,7 +417,7 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["can_add_club"] = PermissionBackend.check_perm(self.request.user, "member.add_club", Club( context["can_add_club"] = PermissionBackend.check_perm(self.request, "member.add_club", Club(
name="", name="",
email="club@example.com", email="club@example.com",
)) ))
@ -403,9 +438,12 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = context["club"] club = self.object
if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club): context["note"] = club.note
if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
club.update_membership_dates() club.update_membership_dates()
# managers list # managers list
managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club", managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club",
date_start__lte=date.today(), date_end__gte=date.today())\ date_start__lte=date.today(), date_end__gte=date.today())\
@ -413,7 +451,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["managers"] = ClubManagerTable(data=managers, prefix="managers-") context["managers"] = ClubManagerTable(data=managers, prefix="managers-")
# transaction history # transaction history
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\ club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\ .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))\
.order_by('-created_at') .order_by('-created_at')
history_table = HistoryTable(club_transactions, prefix="history-") history_table = HistoryTable(club_transactions, prefix="history-")
history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1)) history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
@ -422,7 +460,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
club_member = Membership.objects.filter( club_member = Membership.objects.filter(
club=club, club=club,
date_end__gte=date.today() - timedelta(days=15), date_end__gte=date.today() - timedelta(days=15),
).filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))\ ).filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\
.order_by("user__username", "-date_start") .order_by("user__username", "-date_start")
# Display only the most recent membership # Display only the most recent membership
club_member = club_member.distinct("user__username")\ club_member = club_member.distinct("user__username")\
@ -443,6 +481,29 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_add_members"] = PermissionBackend()\ context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "member.add_membership", empty_membership) .has_perm(self.request.user, "member.add_membership", empty_membership)
# Check permissions to see if the authenticated user can lock/unlock the note
with transaction.atomic():
modified_note = NoteClub.objects.get(pk=club.note.pk)
# Don't log these tests
modified_note._no_signal = True
modified_note.is_active = False
modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
old_note = NoteClub.objects.select_for_update().get(pk=club.note.pk)
modified_note.inactivity_reason = 'forced'
modified_note._force_save = True
modified_note.save()
context["can_force_lock"] = club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
old_note._force_save = True
old_note._no_signal = True
old_note.save()
modified_note.refresh_from_db()
modified_note.is_active = True
context["can_unlock_note"] = not club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
return context return context
@ -459,8 +520,8 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
note = context['object'].note note = context['object'].note
context["aliases"] = AliasTable(note.alias.filter( context["aliases"] = AliasTable(note.alias.filter(
PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all()) PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note, note=context["object"].note,
name="", name="",
normalized_name="", normalized_name="",
@ -535,7 +596,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
form = context['form'] form = context['form']
if "club_pk" in self.kwargs: # We create a new membership. if "club_pk" in self.kwargs: # We create a new membership.
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))\ club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view"))\
.get(pk=self.kwargs["club_pk"], weiclub=None) .get(pk=self.kwargs["club_pk"], weiclub=None)
form.fields['credit_amount'].initial = club.membership_fee_paid form.fields['credit_amount'].initial = club.membership_fee_paid
# Ensure that the user is member of the parent club and all its the family tree. # Ensure that the user is member of the parent club and all its the family tree.
@ -655,8 +716,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
if club.name != "Kfet" and club.parent_club and not Membership.objects.filter( if club.name != "Kfet" and club.parent_club and not Membership.objects.filter(
user=form.instance.user, user=form.instance.user,
club=club.parent_club, club=club.parent_club,
date_start__lte=timezone.now(), date_start__gte=club.parent_club.membership_start,
date_end__gte=club.parent_club.membership_end,
).exists(): ).exists():
form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name) form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name)
error = True error = True
@ -684,7 +744,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
""" """
# Get the club that is concerned by the membership # Get the club that is concerned by the membership
if "club_pk" in self.kwargs: # get from url of new membership if "club_pk" in self.kwargs: # get from url of new membership
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) \ club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view")) \
.get(pk=self.kwargs["club_pk"]) .get(pk=self.kwargs["club_pk"])
user = form.instance.user user = form.instance.user
old_membership = None old_membership = None
@ -868,7 +928,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = Club.objects.filter( club = Club.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Club, "view") PermissionBackend.filter_queryset(self.request, Club, "view")
).get(pk=self.kwargs["pk"]) ).get(pk=self.kwargs["pk"])
context["club"] = club context["club"] = club

View File

@ -8,11 +8,11 @@ from rest_framework.exceptions import ValidationError
from rest_polymorphic.serializers import PolymorphicSerializer from rest_polymorphic.serializers import PolymorphicSerializer
from member.api.serializers import MembershipSerializer from member.api.serializers import MembershipSerializer
from member.models import Membership from member.models import Membership
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias, Trust
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \ from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
RecurrentTransaction, SpecialTransaction RecurrentTransaction, SpecialTransaction
@ -77,6 +77,22 @@ class NoteUserSerializer(serializers.ModelSerializer):
return str(obj) return str(obj)
class TrustSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Trusts.
The djangorestframework plugin will analyse the model `Trust` and parse all fields in the API.
"""
class Meta:
model = Trust
fields = '__all__'
def validate(self, attrs):
instance = Trust(**attrs)
instance.clean()
return attrs
class AliasSerializer(serializers.ModelSerializer): class AliasSerializer(serializers.ModelSerializer):
""" """
REST API Serializer for Aliases. REST API Serializer for Aliases.
@ -126,7 +142,7 @@ class ConsumerSerializer(serializers.ModelSerializer):
""" """
# If the user has no right to see the note, then we only display the note identifier # If the user has no right to see the note, then we only display the note identifier
return NotePolymorphicSerializer().to_representation(obj.note)\ return NotePolymorphicSerializer().to_representation(obj.note)\
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note)\ if PermissionBackend.check_perm(get_current_request(), "note.view_note", obj.note)\
else dict( else dict(
id=obj.note.id, id=obj.note.id,
name=str(obj.note), name=str(obj.note),
@ -142,7 +158,7 @@ class ConsumerSerializer(serializers.ModelSerializer):
def get_membership(self, obj): def get_membership(self, obj):
if isinstance(obj.note, NoteUser): if isinstance(obj.note, NoteUser):
memberships = Membership.objects.filter( memberships = Membership.objects.filter(
PermissionBackend.filter_queryset(get_current_authenticated_user(), Membership, "view")).filter( PermissionBackend.filter_queryset(get_current_request(), Membership, "view")).filter(
user=obj.note.user, user=obj.note.user,
club=2, # Kfet club=2, # Kfet
).order_by("-date_start") ).order_by("-date_start")

View File

@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import NotePolymorphicViewSet, AliasViewSet, ConsumerViewSet, \ from .views import NotePolymorphicViewSet, AliasViewSet, ConsumerViewSet, \
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet, \
TrustViewSet
def register_note_urls(router, path): def register_note_urls(router, path):
@ -11,6 +12,7 @@ def register_note_urls(router, path):
""" """
router.register(path + '/note', NotePolymorphicViewSet) router.register(path + '/note', NotePolymorphicViewSet)
router.register(path + '/alias', AliasViewSet) router.register(path + '/alias', AliasViewSet)
router.register(path + '/trust', TrustViewSet)
router.register(path + '/consumer', ConsumerViewSet) router.register(path + '/consumer', ConsumerViewSet)
router.register(path + '/transaction/category', TemplateCategoryViewSet) router.register(path + '/transaction/category', TemplateCategoryViewSet)

View File

@ -1,5 +1,6 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import re
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
@ -10,12 +11,12 @@ from rest_framework import viewsets
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from note_kfet.middlewares import get_current_session
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\ from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer, \
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial TrustSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial, Trust
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
@ -40,12 +41,11 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
Parse query and apply filters. Parse query and apply filters.
:return: The filtered set of requested notes :return: The filtered set of requested notes
""" """
user = self.request.user queryset = self.queryset.filter(PermissionBackend.filter_queryset(self.request, Note, "view")
get_current_session().setdefault("permission_mask", 42) | PermissionBackend.filter_queryset(self.request, NoteUser, "view")
queryset = self.queryset.filter(PermissionBackend.filter_queryset(user, Note, "view") | PermissionBackend.filter_queryset(self.request, NoteClub, "view")
| PermissionBackend.filter_queryset(user, NoteUser, "view") | PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\
| PermissionBackend.filter_queryset(user, NoteClub, "view") .distinct()
| PermissionBackend.filter_queryset(user, NoteSpecial, "view")).distinct()
alias = self.request.query_params.get("alias", ".*") alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter( queryset = queryset.filter(
@ -57,17 +57,48 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
return queryset.order_by("id") return queryset.order_by("id")
class TrustViewSet(ReadProtectedModelViewSet):
"""
REST Trust View set.
The djangorestframework plugin will get all `Trust` objects, serialize it to JSON with the given serializer,
then render it on /api/note/trust/
"""
queryset = Trust.objects
serializer_class = TrustSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
'$trusted__alias__name', '$trusted__alias__normalized_name']
filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user']
ordering_fields = ['trusting', 'trusted', ]
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']:
# trust relationship can't change people involved
serializer_class.Meta.read_only_fields = ('trusting', 'trusting',)
return serializer_class
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
try:
self.perform_destroy(instance)
except ValidationError as e:
return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
class AliasViewSet(ReadProtectedModelViewSet): class AliasViewSet(ReadProtectedModelViewSet):
""" """
REST API View set. REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
then render it on /api/aliases/ then render it on /api/note/aliases/
""" """
queryset = Alias.objects queryset = Alias.objects
serializer_class = AliasSerializer serializer_class = AliasSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
ordering_fields = ['name', 'normalized_name', ] ordering_fields = ['name', 'normalized_name', ]
def get_serializer_class(self): def get_serializer_class(self):
@ -118,7 +149,8 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
serializer_class = ConsumerSerializer serializer_class = ConsumerSerializer
filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend] filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
ordering_fields = ['name', 'normalized_name', ] ordering_fields = ['name', 'normalized_name', ]
def get_queryset(self): def get_queryset(self):
@ -133,23 +165,31 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
alias = self.request.query_params.get("alias", None) alias = self.request.query_params.get("alias", None)
# Check if this is a valid regex. If not, we won't check regex
try:
re.compile(alias)
valid_regex = True
except (re.error, TypeError):
valid_regex = False
suffix = '__iregex' if valid_regex else '__istartswith'
alias_prefix = '^' if valid_regex else ''
queryset = queryset.prefetch_related('note') queryset = queryset.prefetch_related('note')
if alias: if alias:
# We match first an alias if it is matched without normalization, # We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias. # then if the normalized pattern matches a normalized alias.
queryset = queryset.filter( queryset = queryset.filter(
name__iregex="^" + alias **{f'name{suffix}': alias_prefix + alias}
).union( ).union(
queryset.filter( queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias)) Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(name__iregex="^" + alias) & ~Q(**{f'name{suffix}': alias_prefix + alias})
), ),
all=True).union( all=True).union(
queryset.filter( queryset.filter(
Q(normalized_name__iregex="^" + alias.lower()) Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias)) & ~Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(name__iregex="^" + alias) & ~Q(**{f'name{suffix}': alias_prefix + alias})
), ),
all=True) all=True)
@ -205,7 +245,5 @@ class TransactionViewSet(ReadProtectedModelViewSet):
ordering_fields = ['created_at', 'amount', ] ordering_fields = ['created_at', 'amount', ]
def get_queryset(self): def get_queryset(self):
user = self.request.user return self.model.objects.filter(PermissionBackend.filter_queryset(self.request, self.model, "view"))\
get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\
.order_by("created_at", "id") .order_by("created_at", "id")

View File

@ -0,0 +1,27 @@
# Generated by Django 2.2.24 on 2021-09-05 19:16
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0005_auto_20210313_1235'),
]
operations = [
migrations.CreateModel(
name='Trust',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('trusted', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusted', to='note.Note', verbose_name='trusted')),
('trusting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusting', to='note.Note', verbose_name='trusting')),
],
options={
'verbose_name': 'frienship',
'verbose_name_plural': 'friendships',
'unique_together': {('trusting', 'trusted')},
},
),
]

View File

@ -1,13 +1,13 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser, Trust
from .transactions import MembershipTransaction, Transaction, \ from .transactions import MembershipTransaction, Transaction, \
TemplateCategory, TransactionTemplate, RecurrentTransaction, SpecialTransaction TemplateCategory, TransactionTemplate, RecurrentTransaction, SpecialTransaction
__all__ = [ __all__ = [
# Notes # Notes
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser', 'Alias', 'Trust', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
# Transactions # Transactions
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate', 'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
'RecurrentTransaction', 'SpecialTransaction', 'RecurrentTransaction', 'SpecialTransaction',

View File

@ -217,6 +217,38 @@ class NoteSpecial(Note):
return self.special_type return self.special_type
class Trust(models.Model):
"""
A one-sided trust relationship bertween two users
If another user considers you as your friend, you can transfer money from
them
"""
trusting = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='trusting',
verbose_name=_('trusting')
)
trusted = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='trusted',
verbose_name=_('trusted')
)
class Meta:
verbose_name = _("frienship")
verbose_name_plural = _("friendships")
unique_together = ("trusting", "trusted")
def __str__(self):
return _("Friendship between {trusting} and {trusted}").format(
trusting=str(self.trusting), trusted=str(self.trusted))
class Alias(models.Model): class Alias(models.Model):
""" """
points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance. points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance.

View File

@ -222,6 +222,13 @@ $(document).ready(function () {
}) })
}) })
// Make transfer when pressing Enter on the amount section
$('#amount, #reason, #last_name, #first_name, #bank').keypress((event) => {
if (event.originalEvent.charCode === 13) {
$('#btn_transfer').click()
}
})
$('#btn_transfer').click(function () { $('#btn_transfer').click(function () {
if (LOCK) { return } if (LOCK) { return }
@ -348,14 +355,14 @@ $('#btn_transfer').click(function () {
destination_alias: dest.name destination_alias: dest.name
}).done(function () { }).done(function () {
addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'), addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
[pretty_money(source.quantity * dest.quantity * amount), source.name, + dest.name, gettext('insufficient funds')]), 'danger', 10000) [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, gettext('insufficient funds')]), 'danger', 10000)
reset() reset()
}).fail(function (err) { }).fail(function (err) {
const errObj = JSON.parse(err.responseText) const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText } if (!error) { error = err.responseText }
addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'), addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
[pretty_money(source.quantity * dest.quantity * amount), source.name, + dest.name, error]), 'danger') [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, error]), 'danger')
LOCK = false LOCK = false
}) })
}) })

View File

@ -4,13 +4,13 @@
import html import html
import django_tables2 as tables import django_tables2 as tables
from django.utils.html import format_html from django.utils.html import format_html, mark_safe
from django_tables2.utils import A from django_tables2.utils import A
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models.notes import Alias from .models.notes import Alias, Trust
from .models.transactions import Transaction, TransactionTemplate from .models.transactions import Transaction, TransactionTemplate
from .templatetags.pretty_money import pretty_money from .templatetags.pretty_money import pretty_money
@ -88,16 +88,16 @@ class HistoryTable(tables.Table):
"class": lambda record: "class": lambda record:
str(record.valid).lower() str(record.valid).lower()
+ (' validate' if record.source.is_active and record.destination.is_active and PermissionBackend + (' validate' if record.source.is_active and record.destination.is_active and PermissionBackend
.check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record) .check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record)
else ''), else ''),
"data-toggle": "tooltip", "data-toggle": "tooltip",
"title": lambda record: (_("Click to invalidate") if record.valid else _("Click to validate")) "title": lambda record: (_("Click to invalidate") if record.valid else _("Click to validate"))
if PermissionBackend.check_perm(get_current_authenticated_user(), if PermissionBackend.check_perm(get_current_request(),
"note.change_transaction_invalidity_reason", record) "note.change_transaction_invalidity_reason", record)
and record.source.is_active and record.destination.is_active else None, and record.source.is_active and record.destination.is_active else None,
"onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower() "onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower()
+ ', "' + str(record.__class__.__name__) + '")' + ', "' + str(record.__class__.__name__) + '")'
if PermissionBackend.check_perm(get_current_authenticated_user(), if PermissionBackend.check_perm(get_current_request(),
"note.change_transaction_invalidity_reason", record) "note.change_transaction_invalidity_reason", record)
and record.source.is_active and record.destination.is_active else None, and record.source.is_active and record.destination.is_active else None,
"onmouseover": lambda record: '$("#invalidity_reason_' "onmouseover": lambda record: '$("#invalidity_reason_'
@ -126,7 +126,7 @@ class HistoryTable(tables.Table):
When the validation status is hovered, an input field is displayed to let the user specify an invalidity reason When the validation status is hovered, an input field is displayed to let the user specify an invalidity reason
""" """
has_perm = PermissionBackend \ has_perm = PermissionBackend \
.check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record) .check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record)
val = "" if value else "" val = "" if value else ""
@ -148,6 +148,31 @@ DELETE_TEMPLATE = """
""" """
class TrustTable(tables.Table):
class Meta:
attrs = {
'class': 'table table condensed table-striped',
'id': "trust_table"
}
model = Trust
fields = ("trusted",)
template_name = 'django_tables2/bootstrap4.html'
show_header = False
trusted = tables.Column(attrs={'td': {'class': 'text_center'}})
delete_col = tables.TemplateColumn(
template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
attrs={
'td': {
'class': lambda record: 'col-sm-1'
+ (' d-none' if not PermissionBackend.check_perm(
get_current_request(), "note.delete_trust", record)
else '')}},
verbose_name=_("Delete"),)
class AliasTable(tables.Table): class AliasTable(tables.Table):
class Meta: class Meta:
attrs = { attrs = {
@ -165,7 +190,7 @@ class AliasTable(tables.Table):
extra_context={"delete_trans": _('delete')}, extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': lambda record: 'col-sm-1' + ( attrs={'td': {'class': lambda record: 'col-sm-1' + (
' d-none' if not PermissionBackend.check_perm( ' d-none' if not PermissionBackend.check_perm(
get_current_authenticated_user(), "note.delete_alias", get_current_request(), "note.delete_alias",
record) else '')}}, verbose_name=_("Delete"), ) record) else '')}}, verbose_name=_("Delete"), )
@ -197,6 +222,17 @@ class ButtonTable(tables.Table):
verbose_name=_("Edit"), verbose_name=_("Edit"),
) )
hideshow = tables.Column(
verbose_name=_("Hide/Show"),
accessor="pk",
attrs={
'td': {
'class': 'col-sm-1',
'id': lambda record: "hideshow_" + str(record.pk),
}
},
)
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE, delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')}, extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}}, attrs={'td': {'class': 'col-sm-1'}},
@ -204,3 +240,16 @@ class ButtonTable(tables.Table):
def render_amount(self, value): def render_amount(self, value):
return pretty_money(value) return pretty_money(value)
def order_category(self, queryset, is_descending):
return queryset.order_by(f"{'-' if is_descending else ''}category__name"), True
def render_hideshow(self, record):
val = '<button id="'
val += str(record.pk)
val += '" class="btn btn-secondary btn-sm" \
onclick="hideshow(' + str(record.id) + ',' + \
str(record.display).lower() + ')">'
val += str(_("Hide/Show"))
val += '</button>'
return mark_safe(val)

View File

@ -10,6 +10,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
{# bandeau transfert/crédit/débit/activité #} {# bandeau transfert/crédit/débit/activité #}
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div class="btn-group btn-block">
<div class="btn-group btn-group-toggle btn-block" data-toggle="buttons"> <div class="btn-group btn-group-toggle btn-block" data-toggle="buttons">
<label for="type_transfer" class="btn btn-sm btn-outline-primary active"> <label for="type_transfer" class="btn btn-sm btn-outline-primary active">
<input type="radio" name="transaction_type" id="type_transfer"> <input type="radio" name="transaction_type" id="type_transfer">
@ -25,6 +26,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
{% trans "Debit" %} {% trans "Debit" %}
</label> </label>
{% endif %} {% endif %}
</div>
{# Add shortcuts for opened activites if necessary #}
{% for activity in activities_open %} {% for activity in activities_open %}
<a href="{% url "activity:activity_entry" pk=activity.pk %}" class="btn btn-sm btn-outline-primary"> <a href="{% url "activity:activity_entry" pk=activity.pk %}" class="btn btn-sm btn-outline-primary">
{% trans "Entries" %} {{ activity.name }} {% trans "Entries" %} {{ activity.name }}

View File

@ -31,14 +31,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block extrajavascript %} {% block extrajavascript %}
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() {
let searchbar_obj = $("#search_field");
let timer_on = false;
let timer;
function refreshMatchedWords() { function refreshMatchedWords() {
$("tr").each(function() { $("tr").each(function() {
let pattern = searchbar_obj.val(); let pattern = $('#search_field').val();
if (pattern) { if (pattern) {
$(this).find("td:eq(0), td:eq(1), td:eq(3), td:eq(6)").each(function () { $(this).find("td:eq(0), td:eq(1), td:eq(3), td:eq(6)").each(function () {
$(this).html($(this).text().replace(new RegExp(pattern, 'i'), "<mark>$&</mark>")); $(this).html($(this).text().replace(new RegExp(pattern, 'i'), "<mark>$&</mark>"));
@ -47,13 +42,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
}); });
} }
refreshMatchedWords();
function reloadTable() { function reloadTable() {
let pattern = searchbar_obj.val(); let pattern = $('#search_field').val();
$("#buttons_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + " #buttons_table", refreshMatchedWords); $("#buttons_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + " #buttons_table", refreshMatchedWords);
} }
$(document).ready(function() {
let searchbar_obj = $("#search_field");
let timer_on = false;
let timer;
refreshMatchedWords();
searchbar_obj.keyup(function() { searchbar_obj.keyup(function() {
if (timer_on) if (timer_on)
clearTimeout(timer); clearTimeout(timer);
@ -77,5 +77,28 @@ SPDX-License-Identifier: GPL-3.0-or-later
addMsg('{% trans "Unable to delete button "%} #' + button_id, 'danger') addMsg('{% trans "Unable to delete button "%} #' + button_id, 'danger')
}); });
} }
// on click of button "hide/show", call the API
function hideshow(id, displayed) {
$.ajax({
url: '/api/note/transaction/template/' + id + '/',
type: 'PATCH',
dataType: 'json',
headers: {
'X-CSRFTOKEN': CSRF_TOKEN
},
data: {
display: !displayed
},
success: function() {
if(displayed)
addMsg("{% trans "Button hidden"%}", 'success', 1000)
else addMsg("{% trans "Button displayed"%}", 'success', 1000)
reloadTable()
},
error: function (err) {
addMsg("{% trans "An error occured"%}", 'danger')
}})
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -38,7 +38,7 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
# retrieves only Transaction that user has the right to see. # retrieves only Transaction that user has the right to see.
return Transaction.objects.filter( return Transaction.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Transaction, "view") PermissionBackend.filter_queryset(self.request, Transaction, "view")
).order_by("-created_at").all()[:20] ).order_by("-created_at").all()[:20]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -47,16 +47,16 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
context['special_types'] = NoteSpecial.objects\ context['special_types'] = NoteSpecial.objects\
.filter(PermissionBackend.filter_queryset(self.request.user, NoteSpecial, "view"))\ .filter(PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\
.order_by("special_type").all() .order_by("special_type").all()
# Add a shortcut for entry page for open activities # Add a shortcut for entry page for open activities
if "activity" in settings.INSTALLED_APPS: if "activity" in settings.INSTALLED_APPS:
from activity.models import Activity from activity.models import Activity
activities_open = Activity.objects.filter(open=True).filter( activities_open = Activity.objects.filter(open=True, activity_type__manage_entries=True).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
context["activities_open"] = [a for a in activities_open context["activities_open"] = [a for a in activities_open
if PermissionBackend.check_perm(self.request.user, if PermissionBackend.check_perm(self.request,
"activity.add_entry", "activity.add_entry",
Entry(activity=a, Entry(activity=a,
note=self.request.user.note, ))] note=self.request.user.note, ))]
@ -90,9 +90,9 @@ class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, Sing
if "search" in self.request.GET: if "search" in self.request.GET:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
qs = qs.filter( qs = qs.filter(
Q(name__iregex="^" + pattern) Q(name__iregex=pattern)
| Q(destination__club__name__iregex="^" + pattern) | Q(destination__club__name__iregex=pattern)
| Q(category__name__iregex="^" + pattern) | Q(category__name__iregex=pattern)
| Q(description__iregex=pattern) | Q(description__iregex=pattern)
) )
@ -159,7 +159,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
return self.handle_no_permission() return self.handle_no_permission()
templates = TransactionTemplate.objects.filter( templates = TransactionTemplate.objects.filter(
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view") PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
) )
if not templates.exists(): if not templates.exists():
raise PermissionDenied(_("You can't see any button.")) raise PermissionDenied(_("You can't see any button."))
@ -170,7 +170,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
restrict to the transaction history the user can see. restrict to the transaction history the user can see.
""" """
return Transaction.objects.filter( return Transaction.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Transaction, "view") PermissionBackend.filter_queryset(self.request, Transaction, "view")
).order_by("-created_at").all()[:20] ).order_by("-created_at").all()[:20]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -180,13 +180,13 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
# for each category, find which transaction templates the user can see. # for each category, find which transaction templates the user can see.
for category in categories: for category in categories:
category.templates_filtered = category.templates.filter( category.templates_filtered = category.templates.filter(
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view") PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
).filter(display=True).order_by('name').all() ).filter(display=True).order_by('name').all()
context['categories'] = [cat for cat in categories if cat.templates_filtered] context['categories'] = [cat for cat in categories if cat.templates_filtered]
# some transactiontemplate are put forward to find them easily # some transactiontemplate are put forward to find them easily
context['highlighted'] = TransactionTemplate.objects.filter(highlighted=True).filter( context['highlighted'] = TransactionTemplate.objects.filter(highlighted=True).filter(
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view") PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
).order_by('name').all() ).order_by('name').all()
context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
@ -209,7 +209,7 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView
data = form.cleaned_data if form.is_valid() else {} data = form.cleaned_data if form.is_valid() else {}
transactions = Transaction.objects.annotate(total_amount=F("quantity") * F("amount")).filter( transactions = Transaction.objects.annotate(total_amount=F("quantity") * F("amount")).filter(
PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\ PermissionBackend.filter_queryset(self.request, Transaction, "view"))\
.filter(Q(source=self.object) | Q(destination=self.object)).order_by('-created_at') .filter(Q(source=self.object) | Q(destination=self.object)).order_by('-created_at')
if "source" in data and data["source"]: if "source" in data and data["source"]:

View File

@ -4,7 +4,7 @@
from django.contrib import admin from django.contrib import admin
from note_kfet.admin import admin_site from note_kfet.admin import admin_site
from .models import Permission, PermissionMask, Role from .models import Permission, PermissionVar, PermissionMask, Role
@admin.register(PermissionMask, site=admin_site) @admin.register(PermissionMask, site=admin_site)
@ -15,6 +15,14 @@ class PermissionMaskAdmin(admin.ModelAdmin):
list_display = ('description', 'rank', ) list_display = ('description', 'rank', )
@admin.register(PermissionVar, site=admin_site)
class PermissionVarAdmin(admin.ModelAdmin):
"""
Admin customisation for PermissionVar
"""
list_display = ('name', 'description',)
@admin.register(Permission, site=admin_site) @admin.register(Permission, site=admin_site)
class PermissionAdmin(admin.ModelAdmin): class PermissionAdmin(admin.ModelAdmin):
""" """

View File

@ -4,12 +4,12 @@
from datetime import date from datetime import date
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, F from django.db.models import Q, F
from django.utils import timezone from django.utils import timezone
from note.models import Note, NoteUser, NoteClub, NoteSpecial from note.models import Note, NoteUser, NoteClub, NoteSpecial
from note_kfet.middlewares import get_current_session from note_kfet.middlewares import get_current_request
from member.models import Membership, Club from member.models import Membership, Club
from .decorators import memoize from .decorators import memoize
@ -26,14 +26,31 @@ class PermissionBackend(ModelBackend):
@staticmethod @staticmethod
@memoize @memoize
def get_raw_permissions(user, t): def get_raw_permissions(request, t):
""" """
Query permissions of a certain type for a user, then memoize it. Query permissions of a certain type for a user, then memoize it.
:param user: The owner of the permissions :param request: The current request
:param t: The type of the permissions: view, change, add or delete :param t: The type of the permissions: view, change, add or delete
:return: The queryset of the permissions of the user (memoized) grouped by clubs :return: The queryset of the permissions of the user (memoized) grouped by clubs
""" """
if isinstance(user, AnonymousUser): if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# OAuth2 Authentication
user = request.auth.user
def permission_filter(membership_obj):
query = Q(pk=-1)
for scope in request.auth.scope.split(' '):
permission_id, club_id = scope.split('_')
if int(club_id) == membership_obj.club_id:
query |= Q(pk=permission_id)
return query
else:
user = request.user
def permission_filter(membership_obj):
return Q(mask__rank__lte=request.session.get("permission_mask", 42))
if user.is_anonymous:
# Unauthenticated users have no permissions # Unauthenticated users have no permissions
return Permission.objects.none() return Permission.objects.none()
@ -43,7 +60,7 @@ class PermissionBackend(ModelBackend):
for membership in memberships: for membership in memberships:
for role in membership.roles.all(): for role in membership.roles.all():
for perm in role.permissions.filter(type=t, mask__rank__lte=get_current_session().get("permission_mask", -1)).all(): for perm in role.permissions.filter(permission_filter(membership), type=t).all():
if not perm.permanent: if not perm.permanent:
if membership.date_start > date.today() or membership.date_end < date.today(): if membership.date_start > date.today() or membership.date_end < date.today():
continue continue
@ -52,16 +69,22 @@ class PermissionBackend(ModelBackend):
return perms return perms
@staticmethod @staticmethod
def permissions(user, model, type): def permissions(request, model, type):
""" """
List all permissions of the given user that applies to a given model and a give type List all permissions of the given user that applies to a given model and a give type
:param user: The owner of the permissions :param request: The current request
:param model: The model that the permissions shoud apply :param model: The model that the permissions shoud apply
:param type: The type of the permissions: view, change, add or delete :param type: The type of the permissions: view, change, add or delete
:return: A generator of the requested permissions :return: A generator of the requested permissions
""" """
for permission in PermissionBackend.get_raw_permissions(user, type): if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# OAuth2 Authentication
user = request.auth.user
else:
user = request.user
for permission in PermissionBackend.get_raw_permissions(request, type):
if not isinstance(model.model_class()(), permission.model.model_class()) or not permission.membership: if not isinstance(model.model_class()(), permission.model.model_class()) or not permission.membership:
continue continue
@ -88,20 +111,26 @@ class PermissionBackend(ModelBackend):
@staticmethod @staticmethod
@memoize @memoize
def filter_queryset(user, model, t, field=None): def filter_queryset(request, model, t, field=None):
""" """
Filter a queryset by considering the permissions of a given user. Filter a queryset by considering the permissions of a given user.
:param user: The owner of the permissions that are fetched :param request: The current request
:param model: The concerned model of the queryset :param model: The concerned model of the queryset
:param t: The type of modification (view, add, change, delete) :param t: The type of modification (view, add, change, delete)
:param field: The field of the model to test, if concerned :param field: The field of the model to test, if concerned
:return: A query that corresponds to the filter to give to a queryset :return: A query that corresponds to the filter to give to a queryset
""" """
if user is None or isinstance(user, AnonymousUser): if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# OAuth2 Authentication
user = request.auth.user
else:
user = request.user
if user is None or user.is_anonymous:
# Anonymous users can't do anything # Anonymous users can't do anything
return Q(pk=-1) return Q(pk=-1)
if user.is_superuser and get_current_session().get("permission_mask", -1) >= 42: if user.is_superuser and request.session.get("permission_mask", -1) >= 42:
# Superusers have all rights # Superusers have all rights
return Q() return Q()
@ -110,7 +139,7 @@ class PermissionBackend(ModelBackend):
# Never satisfied # Never satisfied
query = Q(pk=-1) query = Q(pk=-1)
perms = PermissionBackend.permissions(user, model, t) perms = PermissionBackend.permissions(request, model, t)
for perm in perms: for perm in perms:
if perm.field and field != perm.field: if perm.field and field != perm.field:
continue continue
@ -122,7 +151,7 @@ class PermissionBackend(ModelBackend):
@staticmethod @staticmethod
@memoize @memoize
def check_perm(user_obj, perm, obj=None): def check_perm(request, perm, obj=None):
""" """
Check is the given user has the permission over a given object. Check is the given user has the permission over a given object.
The result is then memoized. The result is then memoized.
@ -130,10 +159,19 @@ class PermissionBackend(ModelBackend):
primary key, the result is not memoized. Moreover, the right could change primary key, the result is not memoized. Moreover, the right could change
(e.g. for a transaction, the balance of the user could change) (e.g. for a transaction, the balance of the user could change)
""" """
if user_obj is None or isinstance(user_obj, AnonymousUser): # Requested by a shell
if request is None:
return False return False
sess = get_current_session() user_obj = request.user
sess = request.session
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# OAuth2 Authentication
user_obj = request.auth.user
if user_obj is None or user_obj.is_anonymous:
return False
if user_obj.is_superuser and sess.get("permission_mask", -1) >= 42: if user_obj.is_superuser and sess.get("permission_mask", -1) >= 42:
return True return True
@ -147,16 +185,19 @@ class PermissionBackend(ModelBackend):
ct = ContentType.objects.get_for_model(obj) ct = ContentType.objects.get_for_model(obj)
if any(permission.applies(obj, perm_type, perm_field) if any(permission.applies(obj, perm_type, perm_field)
for permission in PermissionBackend.permissions(user_obj, ct, perm_type)): for permission in PermissionBackend.permissions(request, ct, perm_type)):
return True return True
return False return False
def has_perm(self, user_obj, perm, obj=None): def has_perm(self, user_obj, perm, obj=None):
return PermissionBackend.check_perm(user_obj, perm, obj) # Warning: this does not check that user_obj has the permission,
# but if the current request has the permission.
# This function is implemented for backward compatibility, and should not be used.
return PermissionBackend.check_perm(get_current_request(), perm, obj)
def has_module_perms(self, user_obj, app_label): def has_module_perms(self, user_obj, app_label):
return False return False
def get_all_permissions(self, user_obj, obj=None): def get_all_permissions(self, user_obj, obj=None):
ct = ContentType.objects.get_for_model(obj) ct = ContentType.objects.get_for_model(obj)
return list(self.permissions(user_obj, ct, "view")) return list(self.permissions(get_current_request(), ct, "view"))

View File

@ -5,7 +5,7 @@ from functools import lru_cache
from time import time from time import time
from django.contrib.sessions.models import Session from django.contrib.sessions.models import Session
from note_kfet.middlewares import get_current_session from note_kfet.middlewares import get_current_request
def memoize(f): def memoize(f):
@ -48,11 +48,11 @@ def memoize(f):
last_collect = time() last_collect = time()
# If there is no session, then we don't memoize anything. # If there is no session, then we don't memoize anything.
sess = get_current_session() request = get_current_request()
if sess is None or sess.session_key is None: if request is None or request.session is None or request.session.session_key is None:
return f(*args, **kwargs) return f(*args, **kwargs)
sess_key = sess.session_key sess_key = request.session.session_key
if sess_key not in sess_funs: if sess_key not in sess_funs:
# lru_cache makes the job of memoization # lru_cache makes the job of memoization
# We store only the 512 latest data per session. It has to be enough. # We store only the 512 latest data per session. It has to be enough.

View File

@ -111,12 +111,12 @@
"note", "note",
"alias" "alias"
], ],
"query": "[\"AND\", [\"OR\", {\"note__noteuser__user__memberships__club__name\": \"Kfet\", \"note__noteuser__user__memberships__date_start__lte\": [\"today\"], \"note__noteuser__user__memberships__date_end__gte\": [\"today\"]}, {\"note__noteclub__isnull\": false}], {\"note__is_active\": true}]", "query": "[\"AND\", [\"OR\", {\"note__noteuser__user__memberships__club__name\": \"BDE\", \"note__noteuser__user__memberships__date_start__lte\": [\"today\"], \"note__noteuser__user__memberships__date_end__gte\": [\"today\"]}, {\"note__noteclub__isnull\": false}], {\"note__is_active\": true}]",
"type": "view", "type": "view",
"mask": 1, "mask": 1,
"field": "", "field": "",
"permanent": false, "permanent": false,
"description": "Voir les aliases des notes des clubs et des adhérents du club Kfet" "description": "Voir les aliases des notes des clubs et des adhérents du club BDE"
} }
}, },
{ {
@ -627,7 +627,7 @@
"type": "view", "type": "view",
"mask": 1, "mask": 1,
"field": "", "field": "",
"permanent": false, "permanent": true,
"description": "Voir les personnes qu'on a invitées" "description": "Voir les personnes qu'on a invitées"
} }
}, },
@ -977,7 +977,7 @@
], ],
"query": "[\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}]", "query": "[\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}]",
"type": "view", "type": "view",
"mask": 1, "mask": 2,
"field": "", "field": "",
"permanent": false, "permanent": false,
"description": "Voir les transactions d'un club" "description": "Voir les transactions d'un club"
@ -1235,7 +1235,7 @@
"type": "view", "type": "view",
"mask": 1, "mask": 1,
"field": "", "field": "",
"permanent": false, "permanent": true,
"description": "Voir le dernier WEI" "description": "Voir le dernier WEI"
} }
}, },
@ -1267,7 +1267,7 @@
"type": "add", "type": "add",
"mask": 1, "mask": 1,
"field": "", "field": "",
"permanent": false, "permanent": true,
"description": "M'inscrire au dernier WEI" "description": "M'inscrire au dernier WEI"
} }
}, },
@ -1331,7 +1331,7 @@
"type": "view", "type": "view",
"mask": 1, "mask": 1,
"field": "", "field": "",
"permanent": false, "permanent": true,
"description": "Voir ma propre inscription WEI" "description": "Voir ma propre inscription WEI"
} }
}, },
@ -1379,7 +1379,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "soge_credit", "field": "soge_credit",
"permanent": false, "permanent": true,
"description": "Indiquer si mon inscription WEI est payée par la Société générale tant qu'elle n'est pas validée" "description": "Indiquer si mon inscription WEI est payée par la Société générale tant qu'elle n'est pas validée"
} }
}, },
@ -1427,7 +1427,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "birth_date", "field": "birth_date",
"permanent": false, "permanent": true,
"description": "Modifier la date de naissance de ma propre inscription WEI" "description": "Modifier la date de naissance de ma propre inscription WEI"
} }
}, },
@ -1459,7 +1459,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "gender", "field": "gender",
"permanent": false, "permanent": true,
"description": "Modifier le genre de ma propre inscription WEI" "description": "Modifier le genre de ma propre inscription WEI"
} }
}, },
@ -1491,7 +1491,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "health_issues", "field": "health_issues",
"permanent": false, "permanent": true,
"description": "Modifier mes problèmes de santé de mon inscription WEI" "description": "Modifier mes problèmes de santé de mon inscription WEI"
} }
}, },
@ -1523,7 +1523,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "emergency_contact_name", "field": "emergency_contact_name",
"permanent": false, "permanent": true,
"description": "Modifier le nom du contact en cas d'urgence de mon inscription WEI" "description": "Modifier le nom du contact en cas d'urgence de mon inscription WEI"
} }
}, },
@ -1555,7 +1555,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "emergency_contact_phone", "field": "emergency_contact_phone",
"permanent": false, "permanent": true,
"description": "Modifier le téléphone du contact en cas d'urgence de mon inscription WEI" "description": "Modifier le téléphone du contact en cas d'urgence de mon inscription WEI"
} }
}, },
@ -1699,7 +1699,7 @@
"type": "add", "type": "add",
"mask": 3, "mask": 3,
"field": "", "field": "",
"permanent": false, "permanent": true,
"description": "Créer une adhésion WEI pour le dernier WEI" "description": "Créer une adhésion WEI pour le dernier WEI"
} }
}, },
@ -2003,7 +2003,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "clothing_cut", "field": "clothing_cut",
"permanent": false, "permanent": true,
"description": "Modifier ma coupe de vêtements de mon inscription WEI" "description": "Modifier ma coupe de vêtements de mon inscription WEI"
} }
}, },
@ -2035,7 +2035,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "clothing_size", "field": "clothing_size",
"permanent": false, "permanent": true,
"description": "Modifier la taille de vêtements de mon inscription WEI" "description": "Modifier la taille de vêtements de mon inscription WEI"
} }
}, },
@ -2243,7 +2243,7 @@
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "information_json", "field": "information_json",
"permanent": false, "permanent": true,
"description": "Modifier mes préférences en terme de bus et d'équipe si mon inscription n'est pas validée et que je suis en 2A+" "description": "Modifier mes préférences en terme de bus et d'équipe si mon inscription n'est pas validée et que je suis en 2A+"
} }
}, },
@ -2511,7 +2511,7 @@
"note", "note",
"noteuser" "noteuser"
], ],
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]", "query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "is_active", "field": "is_active",
@ -2527,7 +2527,7 @@
"note", "note",
"noteuser" "noteuser"
], ],
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]", "query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
"type": "change", "type": "change",
"mask": 1, "mask": 1,
"field": "inactivity_reason", "field": "inactivity_reason",
@ -2871,6 +2871,214 @@
"description": "Changer l'image de n'importe quelle note" "description": "Changer l'image de n'importe quelle note"
} }
}, },
{
"model": "permission.permission",
"pk": 184,
"fields": {
"model": [
"note",
"noteclub"
],
"query": "[\"AND\", {\"club\": [\"club\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
"type": "change",
"mask": 3,
"field": "is_active",
"permanent": true,
"description": "(Dé)bloquer la note de son club manuellement"
}
},
{
"model": "permission.permission",
"pk": 185,
"fields": {
"model": [
"note",
"noteclub"
],
"query": "[\"AND\", {\"club\": [\"club\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
"type": "change",
"mask": 3,
"field": "inactivity_reason",
"permanent": true,
"description": "(Dé)bloquer la note de son club et indiquer que cela a été fait manuellement"
}
},
{
"model": "permission.permission",
"pk": 186,
"fields": {
"model": [
"oauth2_provider",
"application"
],
"query": "{\"user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"permanent": true,
"description": "Voir ses applications OAuth2"
}
},
{
"model": "permission.permission",
"pk": 187,
"fields": {
"model": [
"oauth2_provider",
"application"
],
"query": "{\"user\": [\"user\"]}",
"type": "add",
"mask": 1,
"field": "",
"permanent": true,
"description": "Créer une application OAuth2"
}
},
{
"model": "permission.permission",
"pk": 188,
"fields": {
"model": [
"oauth2_provider",
"application"
],
"query": "{\"user\": [\"user\"]}",
"type": "change",
"mask": 1,
"field": "",
"permanent": true,
"description": "Modifier une application OAuth2"
}
},
{
"model": "permission.permission",
"pk": 189,
"fields": {
"model": [
"oauth2_provider",
"application"
],
"query": "{\"user\": [\"user\"]}",
"type": "delete",
"mask": 1,
"field": "",
"permanent": true,
"description": "Supprimer une application OAuth2"
}
},
{
"model": "permission.permission",
"pk": 190,
"fields": {
"model": [
"note",
"trust"
],
"query": "{\"trusting\": [\"user\", \"note\"]}",
"type": "delete",
"mask": 1,
"field": "",
"permanent": false,
"description": "Supprimer une amitié à sa note"
}
},
{
"model": "permission.permission",
"pk": 191,
"fields": {
"model": [
"note",
"trust"
],
"query": "{\"trusting\": [\"user\", \"note\"]}",
"type": "add",
"mask": 1,
"field": "",
"permanent": false,
"description": "Ajouter une amitié à sa note"
}
},
{
"model": "permission.permission",
"pk": 192,
"fields": {
"model": [
"note",
"trust"
],
"query": "{\"trusting__is_active\": true}",
"type": "add",
"mask": 1,
"field": "",
"permanent": false,
"description": "Ajouter une amitié à une note non bloquée"
}
},
{
"model": "permission.permission",
"pk": 193,
"fields": {
"model": [
"note",
"trust"
],
"query": "{\"trusting__is_active\": true}",
"type": "delete",
"mask": 3,
"field": "",
"permanent": false,
"description": "Supprimer une amitié à une note non bloquée"
}
},
{
"model": "permission.permission",
"pk": 194,
"fields": {
"model": [
"note",
"trust"
],
"query": "{}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir toutes les amitiés, y compris celles des non adhérents"
}
},
{
"model": "permission.permission",
"pk": 195,
"fields": {
"model": [
"note",
"trust"
],
"query": "{\"trusting__noteuser__user\": [\"user\"]}",
"type": "view",
"mask": 1,
"field": "",
"permanent": true,
"description": "Voir ses propres amitiés, pour toujours"
}
},
{
"model": "permission.permission",
"pk": 196,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"AND\", {\"source__trusting__trusted\": [\"user\", \"note\"]}, [\"OR\", {\"source__balance__gte\": {\"F\": [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]]}}, {\"valid\": false}]]",
"type": "add",
"mask": 1,
"field": "",
"permanent": false,
"description": "Transférer de l'argent depuis une note amie en restant positif"
}
},
{ {
"model": "permission.role", "model": "permission.role",
"pk": 1, "pk": 1,
@ -2883,6 +3091,7 @@
3, 3,
4, 4,
5, 5,
6,
7, 7,
8, 8,
9, 9,
@ -2890,13 +3099,25 @@
11, 11,
12, 12,
13, 13,
14,
15,
16,
17,
22, 22,
48, 48,
52, 52,
126, 126,
161, 161,
162, 162,
165 165,
186,
187,
188,
189,
190,
191,
195,
196
] ]
} }
}, },
@ -2907,11 +3128,6 @@
"for_club": 2, "for_club": 2,
"name": "Adh\u00e9rent Kfet", "name": "Adh\u00e9rent Kfet",
"permissions": [ "permissions": [
6,
14,
15,
16,
17,
22, 22,
34, 34,
36, 36,
@ -2942,7 +3158,9 @@
158, 158,
159, 159,
160, 160,
179 179,
189,
190
] ]
} }
}, },
@ -3010,7 +3228,9 @@
166, 166,
167, 167,
168, 168,
182 182,
184,
185
] ]
} }
}, },
@ -3048,6 +3268,7 @@
31, 31,
32, 32,
33, 33,
43,
51, 51,
53, 53,
54, 54,
@ -3089,7 +3310,10 @@
176, 176,
177, 177,
178, 178,
183 188,
183,
186,
187
] ]
} }
}, },
@ -3277,7 +3501,20 @@
180, 180,
181, 181,
182, 182,
183 183,
184,
185,
186,
187,
188,
189,
190,
191,
192,
193,
194,
195,
196
] ]
} }
}, },
@ -3304,6 +3541,7 @@
30, 30,
31, 31,
70, 70,
72,
143, 143,
166, 166,
167, 167,
@ -3336,7 +3574,8 @@
45, 45,
46, 46,
148, 148,
149 149,
182
] ]
} }
}, },
@ -3495,7 +3734,7 @@
"model": "permission.role", "model": "permission.role",
"pk": 20, "pk": 20,
"fields": { "fields": {
"for_club": 2, "for_club": 1,
"name": "PC Kfet", "name": "PC Kfet",
"permissions": [ "permissions": [
6, 6,
@ -3511,6 +3750,8 @@
56, 56,
57, 57,
58, 58,
70,
72,
135, 135,
137, 137,
143, 143,

View File

@ -0,0 +1,22 @@
# Generated by Django 2.2.28 on 2022-10-10 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('permission', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PermissionVar',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(unique=True, verbose_name='name')),
('query', models.TextField(verbose_name='query')),
('description', models.CharField(blank=True, max_length=255, verbose_name='description')),
],
),
]

View File

@ -118,6 +118,25 @@ class PermissionMask(models.Model):
verbose_name_plural = _("permission masks") verbose_name_plural = _("permission masks")
class PermissionVar(models.Model):
name = models.SlugField(
unique=True,
blank=False,
verbose_name=_("name"),
)
query = models.TextField(
verbose_name=_("query"),
)
description = models.CharField(
max_length=255,
blank=True,
verbose_name=_("description"),
)
class Permission(models.Model): class Permission(models.Model):
PERMISSION_TYPES = [ PERMISSION_TYPES = [
@ -139,6 +158,7 @@ class Permission(models.Model):
# query -> ["AND", query, …] AND multiple queries # query -> ["AND", query, …] AND multiple queries
# | ["OR", query, …] OR multiple queries # | ["OR", query, …] OR multiple queries
# | ["NOT", query] Opposite of query # | ["NOT", query] Opposite of query
# | ["VAR", query] A var name as defined in PermissionVar
# query -> {key: value, …} A list of fields and values of a Q object # query -> {key: value, …} A list of fields and values of a Q object
# key -> string A field name # key -> string A field name
# value -> int | string | bool | null Literal values # value -> int | string | bool | null Literal values
@ -150,6 +170,7 @@ class Permission(models.Model):
# | ["MUL", oper, …] Multiply F objects or literals # | ["MUL", oper, …] Multiply F objects or literals
# | int | string | bool | null Literal values # | int | string | bool | null Literal values
# | ["F", string] A field # | ["F", string] A field
# | ["VAR", string] A var name as defined in PermissionVar
# #
# Examples: # Examples:
# Q(is_superuser=True) := {"is_superuser": true} # Q(is_superuser=True) := {"is_superuser": true}
@ -215,6 +236,8 @@ class Permission(models.Model):
return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]])
elif oper[0] == 'F': elif oper[0] == 'F':
return F(oper[1]) return F(oper[1])
elif oper[0] == 'VAR':
return compute_f(json.loads(PermissionVar.objects.get(name=oper[1]).query), **kwargs)
else: else:
field = kwargs[oper[0]] field = kwargs[oper[0]]
for i in range(1, len(oper)): for i in range(1, len(oper)):
@ -289,6 +312,8 @@ class Permission(models.Model):
return functools.reduce(operator.or_, [Permission._about(query, **kwargs) for query in query[1:]]) return functools.reduce(operator.or_, [Permission._about(query, **kwargs) for query in query[1:]])
elif query[0] == 'NOT': elif query[0] == 'NOT':
return ~Permission._about(query[1], **kwargs) return ~Permission._about(query[1], **kwargs)
elif query[0] == 'VAR':
return Permission._about(json.loads(PermissionVar.objects.get(name=query[1]).query), **kwargs)
else: else:
return Q(pk=F("pk")) if Permission.compute_param(query, **kwargs) else ~Q(pk=F("pk")) return Q(pk=F("pk")) if Permission.compute_param(query, **kwargs) else ~Q(pk=F("pk"))
elif isinstance(query, dict): elif isinstance(query, dict):

View File

@ -45,7 +45,7 @@ class StrongDjangoObjectPermissions(DjangoObjectPermissions):
perms = self.get_required_object_permissions(request.method, model_cls) perms = self.get_required_object_permissions(request.method, model_cls)
# if not user.has_perms(perms, obj): # if not user.has_perms(perms, obj):
if not all(PermissionBackend.check_perm(user, perm, obj) for perm in perms): if not all(PermissionBackend.check_perm(request, perm, obj) for perm in perms):
# If the user does not have permissions we need to determine if # If the user does not have permissions we need to determine if
# they have read permissions to see 403, or not, and simply see # they have read permissions to see 403, or not, and simply see
# a 404 response. # a 404 response.

57
apps/permission/scopes.py Normal file
View File

@ -0,0 +1,57 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from oauth2_provider.oauth2_validators import OAuth2Validator
from oauth2_provider.scopes import BaseScopes
from member.models import Club
from note_kfet.middlewares import get_current_request
from .backends import PermissionBackend
from .models import Permission
class PermissionScopes(BaseScopes):
"""
An OAuth2 scope is defined by a permission object and a club.
A token will have a subset of permissions from the owner of the application,
and can be useful to make queries through the API with limited privileges.
"""
def get_all_scopes(self):
return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
for p in Permission.objects.all() for club in Club.objects.all()}
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
if not application:
return []
return [f"{p.id}_{p.membership.club.id}"
for t in Permission.PERMISSION_TYPES
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
if not application:
return []
return [f"{p.id}_{p.membership.club.id}"
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
class PermissionOAuth2Validator(OAuth2Validator):
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
"""
User can request as many scope as he wants, including invalid scopes,
but it will have only the permissions he has.
This allows clients to request more permission to get finally a
subset of permissions.
"""
valid_scopes = set()
for t in Permission.PERMISSION_TYPES:
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0]):
scope = f"{p.id}_{p.membership.club.id}"
if scope in scopes:
valid_scopes.add(scope)
request.scopes = valid_scopes
return valid_scopes

View File

@ -3,7 +3,7 @@
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
@ -16,6 +16,9 @@ EXCLUDED = [
'contenttypes.contenttype', 'contenttypes.contenttype',
'logs.changelog', 'logs.changelog',
'migrations.migration', 'migrations.migration',
'oauth2_provider.accesstoken',
'oauth2_provider.grant',
'oauth2_provider.refreshtoken',
'sessions.session', 'sessions.session',
] ]
@ -31,8 +34,8 @@ def pre_save_object(sender, instance, **kwargs):
if hasattr(instance, "_force_save") or hasattr(instance, "_no_signal"): if hasattr(instance, "_force_save") or hasattr(instance, "_no_signal"):
return return
user = get_current_authenticated_user() request = get_current_request()
if user is None: if request is None:
# Action performed on shell is always granted # Action performed on shell is always granted
return return
@ -45,7 +48,7 @@ def pre_save_object(sender, instance, **kwargs):
# We check if the user can change the model # We check if the user can change the model
# If the user has all right on a model, then OK # If the user has all right on a model, then OK
if PermissionBackend.check_perm(user, app_label + ".change_" + model_name, instance): if PermissionBackend.check_perm(request, app_label + ".change_" + model_name, instance):
return return
# In the other case, we check if he/she has the right to change one field # In the other case, we check if he/she has the right to change one field
@ -58,7 +61,14 @@ def pre_save_object(sender, instance, **kwargs):
# If the field wasn't modified, no need to check the permissions # If the field wasn't modified, no need to check the permissions
if old_value == new_value: if old_value == new_value:
continue continue
if not PermissionBackend.check_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance):
if app_label == 'auth' and model_name == 'user' and field.name == 'password' and request.user.is_anonymous:
# We must ignore password changes from anonymous users since it can be done by people that forgot
# their password. We trust password change form.
continue
if not PermissionBackend.check_perm(request, app_label + ".change_" + model_name + "_" + field_name,
instance):
raise PermissionDenied( raise PermissionDenied(
_("You don't have the permission to change the field {field} on this instance of model" _("You don't have the permission to change the field {field} on this instance of model"
" {app_label}.{model_name}.") " {app_label}.{model_name}.")
@ -66,7 +76,7 @@ def pre_save_object(sender, instance, **kwargs):
) )
else: else:
# We check if the user has right to add the object # We check if the user has right to add the object
has_perm = PermissionBackend.check_perm(user, app_label + ".add_" + model_name, instance) has_perm = PermissionBackend.check_perm(request, app_label + ".add_" + model_name, instance)
if not has_perm: if not has_perm:
raise PermissionDenied( raise PermissionDenied(
@ -87,8 +97,8 @@ def pre_delete_object(instance, **kwargs):
# Don't check permissions on force-deleted objects # Don't check permissions on force-deleted objects
return return
user = get_current_authenticated_user() request = get_current_request()
if user is None: if request is None:
# Action performed on shell is always granted # Action performed on shell is always granted
return return
@ -97,7 +107,7 @@ def pre_delete_object(instance, **kwargs):
model_name = model_name_full[1] model_name = model_name_full[1]
# We check if the user has rights to delete the object # We check if the user has rights to delete the object
if not PermissionBackend.check_perm(user, app_label + ".delete_" + model_name, instance): if not PermissionBackend.check_perm(request, app_label + ".delete_" + model_name, instance):
raise PermissionDenied( raise PermissionDenied(
_("You don't have the permission to delete this instance of model {app_label}.{model_name}.") _("You don't have the permission to delete this instance of model {app_label}.{model_name}.")
.format(app_label=app_label, model_name=model_name)) .format(app_label=app_label, model_name=model_name))

View File

@ -8,7 +8,7 @@ from django.urls import reverse_lazy
from django.utils.html import format_html from django.utils.html import format_html
from django_tables2 import A from django_tables2 import A
from member.models import Membership from member.models import Membership
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
@ -20,7 +20,7 @@ class RightsTable(tables.Table):
def render_user(self, value): def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.username s = value.username
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
return s return s
@ -28,7 +28,7 @@ class RightsTable(tables.Table):
def render_club(self, value): def render_club(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.name s = value.name
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value): if PermissionBackend.check_perm(get_current_request(), "member.view_club", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
@ -42,7 +42,7 @@ class RightsTable(tables.Table):
| Q(name="Bureau de club")) | Q(name="Bureau de club"))
& Q(weirole__isnull=True))).all() & Q(weirole__isnull=True))).all()
s = ", ".join(str(role) for role in roles) s = ", ".join(str(role) for role in roles)
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record): if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record):
s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk})) s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
+ "'>" + s + "</a>") + "'>" + s + "</a>")
return s return s

View File

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h2>{% trans "Available scopes" %}</h2>
</div>
<div class="card-body">
<div class="accordion" id="accordionApps">
{% for app, app_scopes in scopes.items %}
<div class="card">
<div class="card-header" id="app-{{ app.name|slugify }}-title">
<a class="text-decoration-none collapsed" href="#" data-toggle="collapse"
data-target="#app-{{ app.name|slugify }}" aria-expanded="false"
aria-controls="app-{{ app.name|slugify }}">
{{ app.name }}
</a>
</div>
<div class="collapse" id="app-{{ app.name|slugify }}" aria-labelledby="app-{{ app.name|slugify }}" data-target="#accordionApps">
<div class="card-body">
{% for scope_id, scope_desc in app_scopes.items %}
<div class="form-group">
<label class="form-check-label" for="scope-{{ app.name|slugify }}-{{ scope_id }}">
<input type="checkbox" id="scope-{{ app.name|slugify }}-{{ scope_id }}"
name="scope-{{ app.name|slugify }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
{{ scope_desc }}
</label>
</div>
{% endfor %}
<p id="url-{{ app.name|slugify }}">
<a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code" target="_blank">
{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code
</a>
</p>
</div>
</div>
</div>
{% empty %}
<p>
{% trans "No applications defined" %}.
<a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}.
</p>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
{% for app in scopes.keys %}
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
element.onchange = function (event) {
let scope = ""
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
if (element.checked) {
scope += element.value + " "
}
}
scope = scope.substr(0, scope.length - 1)
document.getElementById("url-{{ app.name|slugify }}").innerHTML = 'Scopes : ' + scope
+ '<br><a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='+ scope.replaceAll(' ', '%20')
+ '" target="_blank">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='
+ scope.replaceAll(' ', '%20') + '</a>'
}
}
{% endfor %}
</script>
{% endblock %}

View File

@ -1,12 +1,12 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django import template from django import template
from note_kfet.middlewares import get_current_authenticated_user, get_current_session from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from ..backends import PermissionBackend
@stringfilter @stringfilter
@ -14,9 +14,10 @@ def not_empty_model_list(model_name):
""" """
Return True if and only if the current user has right to see any object of the given model. Return True if and only if the current user has right to see any object of the given model.
""" """
user = get_current_authenticated_user() request = get_current_request()
session = get_current_session() user = request.user
if user is None or isinstance(user, AnonymousUser): session = request.session
if user is None or not user.is_authenticated:
return False return False
elif user.is_superuser and session.get("permission_mask", -1) >= 42: elif user.is_superuser and session.get("permission_mask", -1) >= 42:
return True return True
@ -29,11 +30,12 @@ def model_list(model_name, t="view", fetch=True):
""" """
Return the queryset of all visible instances of the given model. Return the queryset of all visible instances of the given model.
""" """
user = get_current_authenticated_user() request = get_current_request()
user = request.user
spl = model_name.split(".") spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1]) ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, t)) qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(request, ct, t))
if user is None or isinstance(user, AnonymousUser): if user is None or not user.is_authenticated:
return qs.none() return qs.none()
if fetch: if fetch:
qs = qs.all() qs = qs.all()
@ -49,7 +51,7 @@ def model_list_length(model_name, t="view"):
def has_perm(perm, obj): def has_perm(perm, obj):
return PermissionBackend.check_perm(get_current_authenticated_user(), perm, obj) return PermissionBackend.check_perm(get_current_request(), perm, obj)
register = template.Library() register = template.Library()

View File

@ -0,0 +1,94 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from member.models import Membership, Club
from note.models import NoteUser
from oauth2_provider.models import Application, AccessToken
from ..models import Role, Permission
class OAuth2TestCase(TestCase):
fixtures = ('initial', )
def setUp(self):
self.user = User.objects.create(
username="toto",
)
self.application = Application.objects.create(
name="Test",
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
user=self.user,
)
def test_oauth2_access(self):
"""
Create a simple OAuth2 access token that only has the right to see data of the current user
and check that this token has required access, and nothing more.
"""
bde = Club.objects.get(name="BDE")
view_user_perm = Permission.objects.get(pk=1) # View own user detail
# Create access token that has access to our own user detail
token = AccessToken.objects.create(
user=self.user,
application=self.application,
scope=f"{view_user_perm.pk}_{bde.pk}",
token=get_random_string(64),
expires=timezone.now() + timedelta(days=365),
)
# No access without token
resp = self.client.get(f'/api/user/{self.user.pk}/')
self.assertEqual(resp.status_code, 403)
# Valid token but user has no membership, so the query is not returning the user object
resp = self.client.get(f'/api/user/{self.user.pk}/', **{'Authorization': f'Bearer {token.token}'})
self.assertEqual(resp.status_code, 404)
# Create membership to validate permissions
NoteUser.objects.create(user=self.user)
membership = Membership.objects.create(user=self.user, club_id=bde.pk)
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
membership.save()
# User is now a member and can now see its own user detail
resp = self.client.get(f'/api/user/{self.user.pk}/', **{'Authorization': f'Bearer {token.token}'})
self.assertEqual(resp.status_code, 200)
# Token is not granted to see profile detail
resp = self.client.get(f'/api/members/profile/{self.user.profile.pk}/',
**{'Authorization': f'Bearer {token.token}'})
self.assertEqual(resp.status_code, 404)
def test_scopes(self):
"""
Ensure that the scopes page is loading.
"""
self.client.force_login(self.user)
resp = self.client.get(reverse('permission:scopes'))
self.assertEqual(resp.status_code, 200)
self.assertIn(self.application, resp.context['scopes'])
self.assertNotIn('1_1', resp.context['scopes'][self.application]) # The user has not this permission
# Create membership to validate permissions
bde = Club.objects.get(name="BDE")
NoteUser.objects.create(user=self.user)
membership = Membership.objects.create(user=self.user, club_id=bde.pk)
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
membership.save()
resp = self.client.get(reverse('permission:scopes'))
self.assertEqual(resp.status_code, 200)
self.assertIn(self.application, resp.context['scopes'])
self.assertIn('1_1', resp.context['scopes'][self.application]) # Now the user has this permission

View File

@ -1,10 +1,17 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.urls import path from django.urls import path
from permission.views import RightsView
from .views import RightsView, ScopesView
app_name = 'permission' app_name = 'permission'
urlpatterns = [ urlpatterns = [
path('rights', RightsView.as_view(), name="rights"), path('rights/', RightsView.as_view(), name="rights"),
] ]
if "oauth2_provider" in settings.INSTALLED_APPS:
urlpatterns += [
path('scopes/', ScopesView.as_view(), name="scopes"),
]

View File

@ -1,6 +1,6 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from collections import OrderedDict
from datetime import date from datetime import date
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -28,7 +28,7 @@ class ProtectQuerysetMixin:
""" """
def get_queryset(self, filter_permissions=True, **kwargs): def get_queryset(self, filter_permissions=True, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter(PermissionBackend.filter_queryset(self.request.user, qs.model, "view")).distinct()\ return qs.filter(PermissionBackend.filter_queryset(self.request, qs.model, "view")).distinct()\
if filter_permissions else qs if filter_permissions else qs
def get_object(self, queryset=None): def get_object(self, queryset=None):
@ -53,7 +53,7 @@ class ProtectQuerysetMixin:
# We could also delete the field, but some views might be affected. # We could also delete the field, but some views might be affected.
meta = form.instance._meta meta = form.instance._meta
for key in form.base_fields: for key in form.base_fields:
if not PermissionBackend.check_perm(self.request.user, if not PermissionBackend.check_perm(self.request,
f"{meta.app_label}.change_{meta.model_name}_" + key, self.object): f"{meta.app_label}.change_{meta.model_name}_" + key, self.object):
form.fields[key].widget = HiddenInput() form.fields[key].widget = HiddenInput()
@ -101,7 +101,7 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView):
# noinspection PyProtectedMember # noinspection PyProtectedMember
app_label, model_name = model_class._meta.app_label, model_class._meta.model_name.lower() app_label, model_name = model_class._meta.app_label, model_class._meta.model_name.lower()
perm = app_label + ".add_" + model_name perm = app_label + ".add_" + model_name
if not PermissionBackend.check_perm(request.user, perm, self.get_sample_object()): if not PermissionBackend.check_perm(request, perm, self.get_sample_object()):
raise PermissionDenied(_("You don't have the permission to add an instance of model " raise PermissionDenied(_("You don't have the permission to add an instance of model "
"{app_label}.{model_name}.").format(app_label=app_label, model_name=model_name)) "{app_label}.{model_name}.").format(app_label=app_label, model_name=model_name))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -143,3 +143,26 @@ class RightsView(TemplateView):
prefix="superusers-") prefix="superusers-")
return context return context
class ScopesView(LoginRequiredMixin, TemplateView):
template_name = "permission/scopes.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
from oauth2_provider.models import Application
from .scopes import PermissionScopes
scopes = PermissionScopes()
context["scopes"] = {}
all_scopes = scopes.get_all_scopes()
for app in Application.objects.filter(user=self.request.user).all():
available_scopes = scopes.get_available_scopes(app)
context["scopes"][app] = OrderedDict()
items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes]
items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
for k, v in items:
context["scopes"][app][k] = v
return context

View File

@ -46,7 +46,8 @@ class SignUpForm(UserCreationForm):
class DeclareSogeAccountOpenedForm(forms.Form): class DeclareSogeAccountOpenedForm(forms.Form):
soge_account = forms.BooleanField( soge_account = forms.BooleanField(
label=_("I declare that I opened a bank account in the Société générale with the BDE partnership."), label=_("I declare that I opened or I will open soon a bank account in the Société générale with the BDE "
"partnership."),
help_text=_("Warning: this engages you to open your bank account. If you finally decides to don't open your " help_text=_("Warning: this engages you to open your bank account. If you finally decides to don't open your "
"account, you will have to pay the BDE membership."), "account, you will have to pay the BDE membership."),
required=False, required=False,

View File

@ -66,9 +66,11 @@ class UserCreateView(CreateView):
profile_form.instance.user = user profile_form.instance.user = user
profile = profile_form.save(commit=False) profile = profile_form.save(commit=False)
user.profile = profile user.profile = profile
user._force_save = True
user.save() user.save()
user.refresh_from_db() user.refresh_from_db()
profile.user = user profile.user = user
profile._force_save = True
profile.save() profile.save()
user.profile.send_email_validation_link() user.profile.send_email_validation_link()
@ -83,6 +85,9 @@ class UserCreateView(CreateView):
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
# Direct access to validation menu if we have the right to validate it
if PermissionBackend.check_perm(self.request, 'auth.view_user', self.object):
return reverse_lazy('registration:future_user_detail', args=(self.object.pk,))
return reverse_lazy('registration:email_validation_sent') return reverse_lazy('registration:email_validation_sent')
@ -110,7 +115,9 @@ class UserValidateView(TemplateView):
self.validlink = True self.validlink = True
user.is_active = user.profile.registration_valid or user.is_superuser user.is_active = user.profile.registration_valid or user.is_superuser
user.profile.email_confirmed = True user.profile.email_confirmed = True
user._force_save = True
user.save() user.save()
user.profile._force_save = True
user.profile.save() user.profile.save()
return self.render_to_response(self.get_context_data(), status=200 if self.validlink else 400) return self.render_to_response(self.get_context_data(), status=200 if self.validlink else 400)
@ -230,9 +237,6 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
kfet = Club.objects.get(name="Kfet") kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
# In 2020, for COVID-19 reasons, the BDE offered 80 € to each new member that opens a Sogé account,
# since there is no WEI.
fee += 8000
ctx["total_fee"] = "{:.02f}".format(fee / 100, ) ctx["total_fee"] = "{:.02f}".format(fee / 100, )
ctx["declare_soge_account"] = SogeCredit.objects.filter(user=user).exists() ctx["declare_soge_account"] = SogeCredit.objects.filter(user=user).exists()
@ -387,7 +391,7 @@ class FutureUserInvalidateView(ProtectQuerysetMixin, LoginRequiredMixin, View):
Delete the pre-registered user which id is given in the URL. Delete the pre-registered user which id is given in the URL.
""" """
user = User.objects.filter(profile__registration_valid=False)\ user = User.objects.filter(profile__registration_valid=False)\
.filter(PermissionBackend.filter_queryset(request.user, User, "change", "is_valid"))\ .filter(PermissionBackend.filter_queryset(request, User, "change", "is_valid"))\
.get(pk=self.kwargs["pk"]) .get(pk=self.kwargs["pk"])
# Delete associated soge credits before # Delete associated soge credits before
SogeCredit.objects.filter(user=user).delete() SogeCredit.objects.filter(user=user).delete()

View File

@ -1,6 +1,6 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from note.api.serializers import SpecialTransactionSerializer from note.api.serializers import SpecialTransactionSerializer
@ -68,6 +68,14 @@ class SogeCreditSerializer(serializers.ModelSerializer):
The djangorestframework plugin will analyse the model `SogeCredit` and parse all fields in the API. The djangorestframework plugin will analyse the model `SogeCredit` and parse all fields in the API.
""" """
@transaction.atomic
def save(self, **kwargs):
# Update soge transactions after creating a credit
instance = super().save(**kwargs)
instance.update_transactions()
instance.save()
return instance
class Meta: class Meta:
model = SogeCredit model = SogeCredit
fields = '__all__' fields = '__all__'

View File

@ -4,11 +4,12 @@
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit from crispy_forms.layout import Submit
from django import forms from django import forms
from django.contrib.auth.models import User
from django.db import transaction from django.db import transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.inputs import AmountInput from note_kfet.inputs import AmountInput, Autocomplete
from .models import Invoice, Product, Remittance, SpecialTransactionProxy from .models import Invoice, Product, Remittance, SpecialTransactionProxy, SogeCredit
class InvoiceForm(forms.ModelForm): class InvoiceForm(forms.ModelForm):
@ -161,3 +162,19 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
class Meta: class Meta:
model = SpecialTransactionProxy model = SpecialTransactionProxy
fields = ('remittance', ) fields = ('remittance', )
class SogeCreditForm(forms.ModelForm):
class Meta:
model = SogeCredit
fields = ('user', )
widgets = {
"user": Autocomplete(
User,
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ...',
},
),
}

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.24 on 2021-10-05 13:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('treasury', '0003_auto_20210321_1034'),
]
operations = [
migrations.AlterField(
model_name='sogecredit',
name='transactions',
field=models.ManyToManyField(blank=True, related_name='_sogecredit_transactions_+', to='note.MembershipTransaction', verbose_name='membership transactions'),
),
]

View File

@ -1,8 +1,9 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from datetime import date from datetime import date
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -11,6 +12,7 @@ from django.db.models import Q
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
from note.models import NoteSpecial, SpecialTransaction, MembershipTransaction, NoteUser from note.models import NoteSpecial, SpecialTransaction, MembershipTransaction, NoteUser
@ -286,6 +288,7 @@ class SogeCredit(models.Model):
transactions = models.ManyToManyField( transactions = models.ManyToManyField(
MembershipTransaction, MembershipTransaction,
related_name="+", related_name="+",
blank=True,
verbose_name=_("membership transactions"), verbose_name=_("membership transactions"),
) )
@ -302,8 +305,55 @@ class SogeCredit(models.Model):
@property @property
def amount(self): def amount(self):
return self.credit_transaction.total if self.valid \ if self.valid:
else sum(transaction.total for transaction in self.transactions.all()) + 8000 return self.credit_transaction.total
amount = sum(transaction.total for transaction in self.transactions.all())
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIMembership
if not WEIMembership.objects.filter(club__weiclub__year=datetime.date.today().year, user=self.user)\
.exists():
# 80 € for people that don't go to WEI
amount += 8000
return amount
def update_transactions(self):
"""
The Sogé credit may be created after the user already paid its memberships.
We query transactions and update the credit, if it is unvalid.
"""
if self.valid or not self.pk:
return
bde = Club.objects.get(name="BDE")
kfet = Club.objects.get(name="Kfet")
bde_qs = Membership.objects.filter(user=self.user, club=bde, date_start__gte=bde.membership_start)
kfet_qs = Membership.objects.filter(user=self.user, club=kfet, date_start__gte=kfet.membership_start)
if bde_qs.exists():
m = bde_qs.get()
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
if kfet_qs.exists():
m = kfet_qs.get()
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIClub
wei = WEIClub.objects.order_by('-year').first()
wei_qs = Membership.objects.filter(user=self.user, club=wei, date_start__gte=wei.membership_start)
if wei_qs.exists():
m = wei_qs.get()
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
for tr in self.transactions.all():
tr.valid = False
tr.save()
def invalidate(self): def invalidate(self):
""" """
@ -365,7 +415,8 @@ class SogeCredit(models.Model):
self.credit_transaction.amount = self.amount self.credit_transaction.amount = self.amount
self.credit_transaction._force_save = True self.credit_transaction._force_save = True
self.credit_transaction.save() self.credit_transaction.save()
super().save(*args, **kwargs)
return super().save(*args, **kwargs)
def delete(self, **kwargs): def delete(self, **kwargs):
""" """
@ -392,6 +443,7 @@ class SogeCredit(models.Model):
# was opened after the validation of the account. # was opened after the validation of the account.
self.credit_transaction.valid = False self.credit_transaction.valid = False
self.credit_transaction.reason += " (invalide)" self.credit_transaction.reason += " (invalide)"
self.credit_transaction._force_save = True
self.credit_transaction.save() self.credit_transaction.save()
super().delete(**kwargs) super().delete(**kwargs)

View File

@ -3,6 +3,7 @@
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load crispy_forms_filters %}
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
@ -27,7 +28,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }} {{ title }}
</h3> </h3>
<div class="card-body"> <div class="card-body">
<div class="input-group">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ..."> <input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
<div class="input-group-append">
<button id="add_sogecredit" class="btn btn-success" data-toggle="modal" data-target="#add-sogecredit-modal">{% trans "Add" %}</button>
</div>
</div>
<div class="form-check"> <div class="form-check">
<label for="invalid_only" class="form-check-label"> <label for="invalid_only" class="form-check-label">
<input id="invalid_only" name="invalid_only" type="checkbox" class="checkboxinput form-check-input" checked> <input id="invalid_only" name="invalid_only" type="checkbox" class="checkboxinput form-check-input" checked>
@ -47,11 +53,32 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} {% endif %}
</div> </div>
</div> </div>
{# Popup to add new Soge credits manually if needed #}
<div class="modal fade" id="add-sogecredit-modal" tabindex="-1" role="dialog" aria-labelledby="addSogeCredit"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="lockNote">{% trans "Add credit from the Société générale" %}</h5>
<button type="button" class="close btn-modal" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{{ form|crispy }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-modal" data-dismiss="modal">{% trans "Close" %}</button>
<button type="button" class="btn btn-success btn-modal" data-dismiss="modal" onclick="addSogeCredit()">{% trans "Add" %}</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function () {
let old_pattern = null; let old_pattern = null;
let searchbar_obj = $("#searchbar"); let searchbar_obj = $("#searchbar");
let invalid_only_obj = $("#invalid_only"); let invalid_only_obj = $("#invalid_only");
@ -69,6 +96,22 @@ SPDX-License-Identifier: GPL-3.0-or-later
searchbar_obj.keyup(reloadTable); searchbar_obj.keyup(reloadTable);
invalid_only_obj.change(reloadTable); invalid_only_obj.change(reloadTable);
});
function addSogeCredit() {
let user_pk = $('#id_user_pk').val()
if (!user_pk)
return
$.post('/api/treasury/soge_credit/?format=json', {
csrfmiddlewaretoken: CSRF_TOKEN,
user: user_pk,
}).done(function() {
addMsg("{% trans "Credit successfully registered" %}", 'success', 10000)
reloadTable()
}).fail(function (xhr) {
errMsg(xhr.responseJSON, 30000)
reloadTable()
})
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -25,7 +25,8 @@ from note_kfet.settings.base import BASE_DIR
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, LinkTransactionToRemittanceForm from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, \
LinkTransactionToRemittanceForm, SogeCreditForm
from .models import Invoice, Product, Remittance, SpecialTransactionProxy, SogeCredit from .models import Invoice, Product, Remittance, SpecialTransactionProxy, SogeCredit
from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable, SogeCreditTable from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable, SogeCreditTable
@ -107,7 +108,7 @@ class InvoiceListView(LoginRequiredMixin, SingleTableView):
name="", name="",
address="", address="",
) )
if not PermissionBackend.check_perm(self.request.user, "treasury.add_invoice", sample_invoice): if not PermissionBackend.check_perm(self.request, "treasury.add_invoice", sample_invoice):
raise PermissionDenied(_("You are not able to see the treasury interface.")) raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -194,7 +195,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
def get(self, request, **kwargs): def get(self, request, **kwargs):
pk = kwargs["pk"] pk = kwargs["pk"]
invoice = Invoice.objects.filter(PermissionBackend.filter_queryset(request.user, Invoice, "view")).get(pk=pk) invoice = Invoice.objects.filter(PermissionBackend.filter_queryset(request, Invoice, "view")).get(pk=pk)
tex = invoice.tex tex = invoice.tex
try: try:
@ -259,7 +260,7 @@ class RemittanceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
context["table"] = RemittanceTable( context["table"] = RemittanceTable(
data=Remittance.objects.filter( data=Remittance.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all()) PermissionBackend.filter_queryset(self.request, Remittance, "view")).all())
context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none()) context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
return context return context
@ -281,7 +282,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
remittance_type_id=1, remittance_type_id=1,
comment="", comment="",
) )
if not PermissionBackend.check_perm(self.request.user, "treasury.add_remittance", sample_remittance): if not PermissionBackend.check_perm(self.request, "treasury.add_remittance", sample_remittance):
raise PermissionDenied(_("You are not able to see the treasury interface.")) raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -290,7 +291,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
opened_remittances = RemittanceTable( opened_remittances = RemittanceTable(
data=Remittance.objects.filter(closed=False).filter( data=Remittance.objects.filter(closed=False).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
prefix="opened-remittances-", prefix="opened-remittances-",
) )
opened_remittances.paginate(page=self.request.GET.get("opened-remittances-page", 1), per_page=10) opened_remittances.paginate(page=self.request.GET.get("opened-remittances-page", 1), per_page=10)
@ -298,7 +299,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
closed_remittances = RemittanceTable( closed_remittances = RemittanceTable(
data=Remittance.objects.filter(closed=True).filter( data=Remittance.objects.filter(closed=True).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
prefix="closed-remittances-", prefix="closed-remittances-",
) )
closed_remittances.paginate(page=self.request.GET.get("closed-remittances-page", 1), per_page=10) closed_remittances.paginate(page=self.request.GET.get("closed-remittances-page", 1), per_page=10)
@ -307,7 +308,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
no_remittance_tr = SpecialTransactionTable( no_remittance_tr = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)), data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance=None).filter( specialtransactionproxy__remittance=None).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
exclude=('remittance_remove', ), exclude=('remittance_remove', ),
prefix="no-remittance-", prefix="no-remittance-",
) )
@ -317,7 +318,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
with_remittance_tr = SpecialTransactionTable( with_remittance_tr = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)), data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance__closed=False).filter( specialtransactionproxy__remittance__closed=False).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
exclude=('remittance_add', ), exclude=('remittance_add', ),
prefix="with-remittance-", prefix="with-remittance-",
) )
@ -342,7 +343,7 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView)
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter( data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all() PermissionBackend.filter_queryset(self.request, Remittance, "view")).all()
context["special_transactions"] = SpecialTransactionTable( context["special_transactions"] = SpecialTransactionTable(
data=data, data=data,
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', )) exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
@ -433,6 +434,11 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
return qs return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['form'] = SogeCreditForm(self.request.POST or None)
return context
class SogeCreditManageView(LoginRequiredMixin, ProtectQuerysetMixin, BaseFormView, DetailView): class SogeCreditManageView(LoginRequiredMixin, ProtectQuerysetMixin, BaseFormView, DetailView):
""" """

View File

@ -1,10 +1,10 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .registration import WEIForm, WEIRegistrationForm, WEIMembershipForm, BusForm, BusTeamForm from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, WEIMembershipForm, BusForm, BusTeamForm
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
__all__ = [ __all__ = [
'WEIForm', 'WEIRegistrationForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm', 'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey', 'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
] ]

View File

@ -6,7 +6,7 @@ from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial from note.models import NoteSpecial, NoteUser
from note_kfet.inputs import AmountInput, DatePickerInput, Autocomplete, ColorWidget from note_kfet.inputs import AmountInput, DatePickerInput, Autocomplete, ColorWidget
from ..models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership, WEIRole from ..models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership, WEIRole
@ -27,6 +27,15 @@ class WEIForm(forms.ModelForm):
class WEIRegistrationForm(forms.ModelForm): class WEIRegistrationForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
if 'user' in cleaned_data:
if not NoteUser.objects.filter(user=cleaned_data['user']).exists():
self.add_error('user', _("The selected user is not validated. Please validate its account first"))
return cleaned_data
class Meta: class Meta:
model = WEIRegistration model = WEIRegistration
exclude = ('wei', ) exclude = ('wei', )
@ -39,8 +48,7 @@ class WEIRegistrationForm(forms.ModelForm):
'placeholder': 'Nom ...', 'placeholder': 'Nom ...',
}, },
), ),
"birth_date": DatePickerInput(options={'defaultDate': '2000-01-01', "birth_date": DatePickerInput(options={'minDate': '1900-01-01',
'minDate': '1900-01-01',
'maxDate': '2100-01-01'}), 'maxDate': '2100-01-01'}),
} }
@ -109,7 +117,8 @@ class WEIMembershipForm(forms.ModelForm):
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
if cleaned_data["team"] is not None and cleaned_data["team"].bus != cleaned_data["bus"]: if 'team' in cleaned_data and cleaned_data["team"] is not None \
and cleaned_data["team"].bus != cleaned_data["bus"]:
self.add_error('bus', _("This team doesn't belong to the given bus.")) self.add_error('bus', _("This team doesn't belong to the given bus."))
return cleaned_data return cleaned_data
@ -135,6 +144,20 @@ class WEIMembershipForm(forms.ModelForm):
} }
class WEIMembership1AForm(WEIMembershipForm):
"""
Used to confirm registrations of first year members without choosing a bus now.
"""
roles = None
def clean(self):
return super(forms.ModelForm, self).clean()
class Meta:
model = WEIMembership
fields = ('credit_type', 'credit_amount', 'last_name', 'first_name', 'bank',)
class BusForm(forms.ModelForm): class BusForm(forms.ModelForm):
class Meta: class Meta:
model = Bus model = Bus

View File

@ -2,11 +2,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
from .wei2021 import WEISurvey2021 from .wei2022 import WEISurvey2022
__all__ = [ __all__ = [
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey', 'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
] ]
CurrentSurvey = WEISurvey2021 CurrentSurvey = WEISurvey2022

View File

@ -1,12 +1,12 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from typing import Optional from typing import Optional, List
from django.db.models import QuerySet from django.db.models import QuerySet
from django.forms import Form from django.forms import Form
from ...models import WEIClub, WEIRegistration, Bus from ...models import WEIClub, WEIRegistration, Bus, WEIMembership
class WEISurveyInformation: class WEISurveyInformation:
@ -50,6 +50,20 @@ class WEIBusInformation:
self.bus.information = d self.bus.information = d
self.bus.save() self.bus.save()
def free_seats(self, surveys: List["WEISurvey"] = None, quotas=None):
if not quotas:
size = self.bus.size
already_occupied = WEIMembership.objects.filter(bus=self.bus).count()
quotas = {self.bus: size - already_occupied}
quota = quotas[self.bus]
valid_surveys = sum(1 for survey in surveys if survey.information.valid
and survey.information.get_selected_bus() == self.bus) if surveys else 0
return quota - valid_surveys
def has_free_seats(self, surveys=None, quotas=None):
return self.free_seats(surveys, quotas) > 0
class WEISurveyAlgorithm: class WEISurveyAlgorithm:
""" """
@ -76,14 +90,20 @@ class WEISurveyAlgorithm:
""" """
Queryset of all first year registrations Queryset of all first year registrations
""" """
return WEIRegistration.objects.filter(wei__year=cls.get_survey_class().get_year(), first_year=True) if not hasattr(cls, '_registrations'):
cls._registrations = WEIRegistration.objects.filter(wei__year=cls.get_survey_class().get_year(),
first_year=True).all()
return cls._registrations
@classmethod @classmethod
def get_buses(cls) -> QuerySet: def get_buses(cls) -> QuerySet:
""" """
Queryset of all buses of the associated wei. Queryset of all buses of the associated wei.
""" """
return Bus.objects.filter(wei__year=cls.get_survey_class().get_year()) if not hasattr(cls, '_buses'):
cls._buses = Bus.objects.filter(wei__year=cls.get_survey_class().get_year(), size__gt=0).all()
return cls._buses
@classmethod @classmethod
def get_bus_information(cls, bus): def get_bus_information(cls, bus):
@ -125,7 +145,10 @@ class WEISurvey:
""" """
The WEI associated to this kind of survey. The WEI associated to this kind of survey.
""" """
return WEIClub.objects.get(year=cls.get_year()) if not hasattr(cls, '_wei'):
cls._wei = WEIClub.objects.get(year=cls.get_year())
return cls._wei
@classmethod @classmethod
def get_survey_information_class(cls): def get_survey_information_class(cls):
@ -192,3 +215,23 @@ class WEISurvey:
self.information.selected_bus_pk = bus.pk self.information.selected_bus_pk = bus.pk
self.information.selected_bus_name = bus.name self.information.selected_bus_name = bus.name
self.information.valid = True self.information.valid = True
def free(self) -> None:
"""
Unselect the select bus.
"""
self.information.selected_bus_pk = None
self.information.selected_bus_name = None
self.information.valid = False
@classmethod
def clear_cache(cls):
"""
Clear stored information.
"""
if hasattr(cls, '_wei'):
del cls._wei
if hasattr(cls.get_algorithm_class(), '_registrations'):
del cls.get_algorithm_class()._registrations
if hasattr(cls.get_algorithm_class(), '_buses'):
del cls.get_algorithm_class()._buses

View File

@ -1,14 +1,17 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import time import time
from functools import lru_cache
from random import Random from random import Random
from django import forms from django import forms
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import Bus from ...models import WEIMembership
WORDS = [ WORDS = [
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant', '13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
@ -41,19 +44,31 @@ class WEISurveyForm2021(forms.Form):
if not information.seed: if not information.seed:
information.seed = int(1000 * time.time()) information.seed = int(1000 * time.time())
information.save(registration) information.save(registration)
registration._force_save = True
registration.save() registration.save()
rng = Random(information.seed)
words = []
for _ in range(information.step + 1):
# Generate N times words
words = [rng.choice(WORDS) for _ in range(10)]
words = [(w, w) for w in words]
if self.data: if self.data:
self.fields["word"].choices = [(w, w) for w in WORDS] self.fields["word"].choices = [(w, w) for w in WORDS]
if self.is_valid(): if self.is_valid():
return return
rng = Random((information.step + 1) * information.seed)
words = None
buses = WEISurveyAlgorithm2021.get_buses()
informations = {bus: WEIBusInformation2021(bus) for bus in buses}
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
average_score = sum(scores) / len(scores)
preferred_words = {bus: [word for word in WORDS
if informations[bus].scores[word] >= average_score]
for bus in buses}
while words is None or len(set(words)) != len(words):
# Ensure that there is no the same word 2 times
words = [rng.choice(words) for _ignored2, words in preferred_words.items()]
rng.shuffle(words)
words = [(w, w) for w in words]
self.fields["word"].choices = words self.fields["word"].choices = words
@ -124,12 +139,46 @@ class WEISurvey2021(WEISurvey):
""" """
return self.information.step == 20 return self.information.step == 20
@classmethod
@lru_cache()
def word_mean(cls, word):
"""
Calculate the mid-score given by all buses.
"""
buses = cls.get_algorithm_class().get_buses()
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
@lru_cache()
def score(self, bus):
if not self.is_complete():
raise ValueError("Survey is not ended, can't calculate score")
bus_info = self.get_algorithm_class().get_bus_information(bus)
# Score is the given score by the bus subtracted to the mid-score of the buses.
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
return s
@lru_cache()
def scores_per_bus(self):
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
@lru_cache()
def ordered_buses(self):
values = list(self.scores_per_bus().items())
values.sort(key=lambda item: -item[1])
return values
@classmethod
def clear_cache(cls):
cls.word_mean.cache_clear()
return super().clear_cache()
class WEISurveyAlgorithm2021(WEISurveyAlgorithm): class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
""" """
The algorithm class for the year 2021. The algorithm class for the year 2021.
For now, the algorithm is quite simple: the selected bus is the chosen bus. We use Gale-Shapley algorithm to attribute 1y students into buses.
TODO: Improve this algorithm.
""" """
@classmethod @classmethod
@ -140,9 +189,105 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
def get_bus_information_class(cls): def get_bus_information_class(cls):
return WEIBusInformation2021 return WEIBusInformation2021
def run_algorithm(self): def run_algorithm(self, display_tqdm=False):
for registration in self.get_registrations(): """
survey = self.get_survey_class()(registration) Gale-Shapley algorithm implementation.
rng = Random(survey.information.seed) We modify it to allow buses to have multiple "weddings".
survey.select_bus(rng.choice(Bus.objects.all())) """
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
# Don't manage hardcoded people
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
# Reset previous algorithm run
for survey in surveys:
survey.free()
survey.save() survey.save()
non_men = [s for s in surveys if s.registration.gender != 'male']
men = [s for s in surveys if s.registration.gender == 'male']
quotas = {}
registrations = self.get_registrations()
non_men_total = registrations.filter(~Q(gender='male')).count()
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
tqdm_obj = None
if display_tqdm:
from tqdm import tqdm
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
# Repartition for non men people first
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
quotas = {}
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = free_seats
if display_tqdm:
tqdm_obj.close()
from tqdm import tqdm
tqdm_obj = tqdm(total=len(men), desc="Hommes")
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
if display_tqdm:
tqdm_obj.close()
# Clear cache information after running algorithm
WEISurvey2021.clear_cache()
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
free_surveys = surveys.copy() # Remaining surveys
while free_surveys: # Some students are not affected
survey = free_surveys[0]
buses = survey.ordered_buses() # Preferences of the student
for bus, current_score in buses:
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
# Selected bus has free places. Put student in the bus
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
# Current bus has not enough places. Remove the least preferred student from the bus if existing
least_preferred_survey = None
least_score = -1
# Find the least student in the bus that has a lower score than the current student
for survey2 in surveys:
if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
continue
score2 = survey2.score(bus)
if current_score <= score2: # Ignore better students
continue
if least_preferred_survey is None or score2 < least_score:
least_preferred_survey = survey2
least_score = score2
if least_preferred_survey is not None:
# Remove the least student from the bus and put the current student in.
# If it does not exist, choose the next bus.
least_preferred_survey.free()
least_preferred_survey.save()
free_surveys.append(least_preferred_survey)
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
raise ValueError(f"User {survey.registration.user} has no free seat")
if tqdm_obj is not None:
tqdm_obj.n = len(surveys) - len(free_surveys)
tqdm_obj.refresh()

View File

@ -0,0 +1,293 @@
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import time
from functools import lru_cache
from random import Random
from django import forms
from django.db import transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import WEIMembership
WORDS = [
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
]
class WEISurveyForm2022(forms.Form):
"""
Survey form for the year 2022.
Members choose 20 words, from which we calculate the best associated bus.
"""
word = forms.ChoiceField(
label=_("Choose a word:"),
widget=forms.RadioSelect(),
)
def set_registration(self, registration):
"""
Filter the bus selector with the buses of the current WEI.
"""
information = WEISurveyInformation2022(registration)
if not information.seed:
information.seed = int(1000 * time.time())
information.save(registration)
registration._force_save = True
registration.save()
if self.data:
self.fields["word"].choices = [(w, w) for w in WORDS]
if self.is_valid():
return
rng = Random((information.step + 1) * information.seed)
words = None
buses = WEISurveyAlgorithm2022.get_buses()
informations = {bus: WEIBusInformation2022(bus) for bus in buses}
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
average_score = sum(scores) / len(scores)
preferred_words = {bus: [word for word in WORDS
if informations[bus].scores[word] >= average_score]
for bus in buses}
while words is None or len(set(words)) != len(words):
# Ensure that there is no the same word 2 times
words = [rng.choice(words) for _ignored2, words in preferred_words.items()]
rng.shuffle(words)
words = [(w, w) for w in words]
self.fields["word"].choices = words
class WEIBusInformation2022(WEIBusInformation):
"""
For each word, the bus has a score
"""
scores: dict
def __init__(self, bus):
self.scores = {}
for word in WORDS:
self.scores[word] = 0.0
super().__init__(bus)
class WEISurveyInformation2022(WEISurveyInformation):
"""
We store the id of the selected bus. We store only the name, but is not used in the selection:
that's only for humans that try to read data.
"""
# Random seed that is stored at the first time to ensure that words are generated only once
seed = 0
step = 0
def __init__(self, registration):
for i in range(1, 21):
setattr(self, "word" + str(i), None)
super().__init__(registration)
class WEISurvey2022(WEISurvey):
"""
Survey for the year 2022.
"""
@classmethod
def get_year(cls):
return 2022
@classmethod
def get_survey_information_class(cls):
return WEISurveyInformation2022
def get_form_class(self):
return WEISurveyForm2022
def update_form(self, form):
"""
Filter the bus selector with the buses of the WEI.
"""
form.set_registration(self.registration)
@transaction.atomic
def form_valid(self, form):
word = form.cleaned_data["word"]
self.information.step += 1
setattr(self.information, "word" + str(self.information.step), word)
self.save()
@classmethod
def get_algorithm_class(cls):
return WEISurveyAlgorithm2022
def is_complete(self) -> bool:
"""
The survey is complete once the bus is chosen.
"""
return self.information.step == 20
@classmethod
@lru_cache()
def word_mean(cls, word):
"""
Calculate the mid-score given by all buses.
"""
buses = cls.get_algorithm_class().get_buses()
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
@lru_cache()
def score(self, bus):
if not self.is_complete():
raise ValueError("Survey is not ended, can't calculate score")
bus_info = self.get_algorithm_class().get_bus_information(bus)
# Score is the given score by the bus subtracted to the mid-score of the buses.
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
return s
@lru_cache()
def scores_per_bus(self):
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
@lru_cache()
def ordered_buses(self):
values = list(self.scores_per_bus().items())
values.sort(key=lambda item: -item[1])
return values
@classmethod
def clear_cache(cls):
cls.word_mean.cache_clear()
return super().clear_cache()
class WEISurveyAlgorithm2022(WEISurveyAlgorithm):
"""
The algorithm class for the year 2022.
We use Gale-Shapley algorithm to attribute 1y students into buses.
"""
@classmethod
def get_survey_class(cls):
return WEISurvey2022
@classmethod
def get_bus_information_class(cls):
return WEIBusInformation2022
def run_algorithm(self, display_tqdm=False):
"""
Gale-Shapley algorithm implementation.
We modify it to allow buses to have multiple "weddings".
"""
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
# Don't manage hardcoded people
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
# Reset previous algorithm run
for survey in surveys:
survey.free()
survey.save()
non_men = [s for s in surveys if s.registration.gender != 'male']
men = [s for s in surveys if s.registration.gender == 'male']
quotas = {}
registrations = self.get_registrations()
non_men_total = registrations.filter(~Q(gender='male')).count()
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
tqdm_obj = None
if display_tqdm:
from tqdm import tqdm
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
# Repartition for non men people first
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
quotas = {}
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = free_seats
if display_tqdm:
tqdm_obj.close()
from tqdm import tqdm
tqdm_obj = tqdm(total=len(men), desc="Hommes")
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
if display_tqdm:
tqdm_obj.close()
# Clear cache information after running algorithm
WEISurvey2022.clear_cache()
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
free_surveys = surveys.copy() # Remaining surveys
while free_surveys: # Some students are not affected
survey = free_surveys[0]
buses = survey.ordered_buses() # Preferences of the student
for bus, current_score in buses:
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
# Selected bus has free places. Put student in the bus
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
# Current bus has not enough places. Remove the least preferred student from the bus if existing
least_preferred_survey = None
least_score = -1
# Find the least student in the bus that has a lower score than the current student
for survey2 in surveys:
if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
continue
score2 = survey2.score(bus)
if current_score <= score2: # Ignore better students
continue
if least_preferred_survey is None or score2 < least_score:
least_preferred_survey = survey2
least_score = score2
if least_preferred_survey is not None:
# Remove the least student from the bus and put the current student in.
# If it does not exist, choose the next bus.
least_preferred_survey.free()
least_preferred_survey.save()
free_surveys.append(least_preferred_survey)
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
raise ValueError(f"User {survey.registration.user} has no free seat")
if tqdm_obj is not None:
tqdm_obj.n = len(surveys) - len(free_surveys)
tqdm_obj.refresh()

View File

@ -0,0 +1,50 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import argparse
import sys
from django.core.management import BaseCommand
from django.db import transaction
from ...forms import CurrentSurvey
from ...forms.surveys.wei2021 import WORDS # WARNING: this is specific to 2021
from ...models import Bus
class Command(BaseCommand):
"""
This script is used to load scores for buses from a CSV file.
"""
def add_arguments(self, parser):
parser.add_argument('file', nargs='?', type=argparse.FileType('r'), default=sys.stdin, help='Input CSV file')
@transaction.atomic
def handle(self, *args, **options):
file = options['file']
head = file.readline().replace('\n', '')
bus_names = head.split(';')
bus_names = [name for name in bus_names if name]
buses = []
for name in bus_names:
qs = Bus.objects.filter(name__iexact=name)
if not qs.exists():
raise ValueError(f"Bus '{name}' does not exist")
buses.append(qs.get())
informations = {bus: CurrentSurvey.get_algorithm_class().get_bus_information(bus) for bus in buses}
for line in file:
elem = line.split(';')
word = elem[0]
if word not in WORDS:
raise ValueError(f"Word {word} is not used")
for i, bus in enumerate(buses):
info = informations[bus]
info.scores[word] = float(elem[i + 1].replace(',', '.'))
for bus, info in informations.items():
info.save()
bus.save()
if options['verbosity'] > 0:
self.stdout.write(f"Bus {bus.name} saved!")

View File

@ -5,7 +5,7 @@ from argparse import ArgumentParser, FileType
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.db import transaction from django.db import transaction
from wei.forms import CurrentSurvey from ...forms import CurrentSurvey
class Command(BaseCommand): class Command(BaseCommand):
@ -24,17 +24,31 @@ class Command(BaseCommand):
sid = transaction.savepoint() sid = transaction.savepoint()
algorithm = CurrentSurvey.get_algorithm_class()() algorithm = CurrentSurvey.get_algorithm_class()()
algorithm.run_algorithm()
try:
from tqdm import tqdm
del tqdm
display_tqdm = True
except ImportError:
display_tqdm = False
algorithm.run_algorithm(display_tqdm=display_tqdm)
output = options['output'] output = options['output']
registrations = algorithm.get_registrations() registrations = algorithm.get_registrations()
per_bus = {bus: [r for r in registrations if r.information['selected_bus_pk'] == bus.pk] per_bus = {bus: [r for r in registrations if 'selected_bus_pk' in r.information
and r.information['selected_bus_pk'] == bus.pk]
for bus in algorithm.get_buses()} for bus in algorithm.get_buses()}
for bus, members in per_bus.items(): for bus, members in per_bus.items():
output.write(bus.name + "\n") output.write(bus.name + "\n")
output.write("=" * len(bus.name) + "\n") output.write("=" * len(bus.name) + "\n")
_order = -1
for r in members: for r in members:
output.write(r.user.username + "\n") survey = CurrentSurvey(r)
for _order, (b, _score) in enumerate(survey.ordered_buses()):
if b == bus:
break
output.write(f"{r.user.username} ({_order + 1})\n")
output.write("\n") output.write("\n")
if not options['doit']: if not options['doit']:

View File

@ -7,6 +7,7 @@ from datetime import date
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from member.models import Club, Membership from member.models import Club, Membership
@ -98,6 +99,13 @@ class Bus(models.Model):
""" """
self.information_json = json.dumps(information, indent=2) self.information_json = json.dumps(information, indent=2)
@property
def suggested_first_year(self):
registrations = WEIRegistration.objects.filter(Q(membership__isnull=True) | Q(membership__bus__isnull=True),
first_year=True, wei=self.wei)
registrations = [r for r in registrations if 'selected_bus_pk' in r.information]
return sum(1 for r in registrations if r.information['selected_bus_pk'] == self.pk)
def __str__(self): def __str__(self):
return self.name return self.name
@ -262,6 +270,33 @@ class WEIRegistration(models.Model):
""" """
self.information_json = json.dumps(information, indent=2) self.information_json = json.dumps(information, indent=2)
@property
def fee(self):
bde = Club.objects.get(pk=1)
kfet = Club.objects.get(pk=2)
kfet_member = Membership.objects.filter(
club_id=kfet.id,
user=self.user,
date_start__gte=kfet.membership_start,
).exists()
bde_member = Membership.objects.filter(
club_id=bde.id,
user=self.user,
date_start__gte=bde.membership_start,
).exists()
fee = self.wei.membership_fee_paid if self.user.profile.paid \
else self.wei.membership_fee_unpaid
if not kfet_member:
fee += kfet.membership_fee_paid if self.user.profile.paid \
else kfet.membership_fee_unpaid
if not bde_member:
fee += bde.membership_fee_paid if self.user.profile.paid \
else bde.membership_fee_unpaid
return fee
@property @property
def is_validated(self): def is_validated(self):
try: try:
@ -337,8 +372,19 @@ class WEIMembership(Membership):
# to treasurers. # to treasurers.
transaction.refresh_from_db() transaction.refresh_from_db()
from treasury.models import SogeCredit from treasury.models import SogeCredit
soge_credit = SogeCredit.objects.get_or_create(user=self.user)[0] soge_credit, created = SogeCredit.objects.get_or_create(user=self.user)
soge_credit.refresh_from_db() soge_credit.refresh_from_db()
transaction.save() transaction.save()
soge_credit.transactions.add(transaction) soge_credit.transactions.add(transaction)
soge_credit.save() soge_credit.save()
soge_credit.update_transactions()
soge_credit.save()
if soge_credit.valid and \
soge_credit.credit_transaction.total != sum(tr.total for tr in soge_credit.transactions.all()):
# The credit is already validated, but we add a new transaction (eg. for the WEI).
# Then we invalidate the transaction, update the credit transaction amount
# and re-validate the credit.
soge_credit.validate(True)
soge_credit.save()

View File

@ -4,11 +4,12 @@
from datetime import date from datetime import date
import django_tables2 as tables import django_tables2 as tables
from django.db.models import Q
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_tables2 import A from django_tables2 import A
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership from .models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership
@ -43,6 +44,7 @@ class WEIRegistrationTable(tables.Table):
edit = tables.LinkColumn( edit = tables.LinkColumn(
'wei:wei_update_registration', 'wei:wei_update_registration',
orderable=False,
args=[A('pk')], args=[A('pk')],
verbose_name=_("Edit"), verbose_name=_("Edit"),
text=_("Edit"), text=_("Edit"),
@ -53,18 +55,14 @@ class WEIRegistrationTable(tables.Table):
} }
} }
) )
validate = tables.LinkColumn(
'wei:validate_registration', validate = tables.Column(
args=[A('pk')],
verbose_name=_("Validate"), verbose_name=_("Validate"),
text=_("Validate"), orderable=False,
accessor=A('pk'),
attrs={ attrs={
'th': { 'th': {
'id': 'validate-membership-header' 'id': 'validate-membership-header'
},
'a': {
'class': 'btn btn-success',
'data-type': 'validate-membership'
} }
} }
) )
@ -72,6 +70,7 @@ class WEIRegistrationTable(tables.Table):
delete = tables.LinkColumn( delete = tables.LinkColumn(
'wei:wei_delete_registration', 'wei:wei_delete_registration',
args=[A('pk')], args=[A('pk')],
orderable=False,
verbose_name=_("delete"), verbose_name=_("delete"),
text=_("Delete"), text=_("Delete"),
attrs={ attrs={
@ -87,7 +86,7 @@ class WEIRegistrationTable(tables.Table):
def render_validate(self, record): def render_validate(self, record):
hasperm = PermissionBackend.check_perm( hasperm = PermissionBackend.check_perm(
get_current_authenticated_user(), "wei.add_weimembership", WEIMembership( get_current_request(), "wei.add_weimembership", WEIMembership(
club=record.wei, club=record.wei,
user=record.user, user=record.user,
date_start=date.today(), date_start=date.today(),
@ -96,10 +95,26 @@ class WEIRegistrationTable(tables.Table):
registration=record, registration=record,
) )
) )
return _("Validate") if hasperm else format_html("<span class='no-perm'></span>") if not hasperm:
return format_html("<span class='no-perm'></span>")
url = reverse_lazy('wei:validate_registration', args=(record.pk,))
text = _('Validate')
if record.fee > record.user.note.balance and not record.soge_credit:
btn_class = 'btn-secondary'
tooltip = _("The user does not have enough money.")
elif record.first_year:
btn_class = 'btn-info'
tooltip = _("The user is in first year. You may validate the credit, the algorithm will run later.")
else:
btn_class = 'btn-success'
tooltip = _("The user has enough money, you can validate the registration.")
return format_html(f"<a class=\"btn {btn_class}\" data-type='validate-membership' data-toggle=\"tooltip\" "
f"title=\"{tooltip}\" href=\"{url}\">{text}</a>")
def render_delete(self, record): def render_delete(self, record):
hasperm = PermissionBackend.check_perm(get_current_authenticated_user(), "wei.delete_weimembership", record) hasperm = PermissionBackend.check_perm(get_current_request(), "wei.delete_weimembership", record)
return _("Delete") if hasperm else format_html("<span class='no-perm'></span>") return _("Delete") if hasperm else format_html("<span class='no-perm'></span>")
class Meta: class Meta:
@ -108,7 +123,8 @@ class WEIRegistrationTable(tables.Table):
} }
model = WEIRegistration model = WEIRegistration
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'caution_check',) fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'caution_check',
'edit', 'validate', 'delete',)
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'id': lambda record: "row-" + str(record.pk), 'id': lambda record: "row-" + str(record.pk),
@ -154,6 +170,35 @@ class WEIMembershipTable(tables.Table):
} }
class WEIRegistration1ATable(tables.Table):
user = tables.LinkColumn(
'wei:wei_bus_1A',
args=[A('pk')],
)
preferred_bus = tables.Column(
verbose_name=_('preferred bus').capitalize,
accessor='pk',
orderable=False,
)
def render_preferred_bus(self, record):
information = record.information
return information['selected_bus_name'] if 'selected_bus_name' in information else ""
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = WEIRegistration
template_name = 'django_tables2/bootstrap4.html'
fields = ('user', 'user__last_name', 'user__first_name', 'gender',
'user__profile__department', 'preferred_bus', 'membership__bus', )
row_attrs = {
'class': lambda record: '' if 'selected_bus_pk' in record.information else 'bg-danger',
}
class BusTable(tables.Table): class BusTable(tables.Table):
name = tables.LinkColumn( name = tables.LinkColumn(
'wei:manage_bus', 'wei:manage_bus',
@ -230,3 +275,66 @@ class BusTeamTable(tables.Table):
'id': lambda record: "row-" + str(record.pk), 'id': lambda record: "row-" + str(record.pk),
'data-href': lambda record: reverse_lazy('wei:manage_bus_team', args=(record.pk, )) 'data-href': lambda record: reverse_lazy('wei:manage_bus_team', args=(record.pk, ))
} }
class BusRepartitionTable(tables.Table):
name = tables.Column(
verbose_name=_("name").capitalize,
accessor='name',
)
suggested_first_year = tables.Column(
verbose_name=_("suggested first year").capitalize,
accessor='pk',
orderable=False,
)
validated_first_year = tables.Column(
verbose_name=_("validated first year").capitalize,
accessor='pk',
orderable=False,
)
validated_staff = tables.Column(
verbose_name=_("validated staff").capitalize,
accessor='pk',
orderable=False,
)
size = tables.Column(
verbose_name=_("seat count in the bus").capitalize,
accessor='size',
)
free_seats = tables.Column(
verbose_name=_("free seats").capitalize,
accessor='pk',
orderable=False,
)
def render_suggested_first_year(self, record):
registrations = WEIRegistration.objects.filter(Q(membership__isnull=True) | Q(membership__bus__isnull=True),
first_year=True, wei=record.wei)
registrations = [r for r in registrations if 'selected_bus_pk' in r.information]
return sum(1 for r in registrations if r.information['selected_bus_pk'] == record.pk)
def render_validated_first_year(self, record):
return WEIRegistration.objects.filter(first_year=True, membership__bus=record).count()
def render_validated_staff(self, record):
return WEIRegistration.objects.filter(first_year=False, membership__bus=record).count()
def render_free_seats(self, record):
return record.size - self.render_validated_staff(record) - self.render_validated_first_year(record)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
models = Bus
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', )
row_attrs = {
'class': 'table-row',
'id': lambda record: "row-" + str(record.pk),
}

View File

@ -0,0 +1,20 @@
{% extends "wei/base.html" %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block profile_content %}
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Attribute first year members into buses" %}</h3>
</div>
<div class="card-body">
{% render_table bus_repartition_table %}
<hr>
<a href="{% url 'wei:wei_bus_1A_next' pk=club.pk %}" class="btn btn-block btn-success">{% trans "Start attribution!" %}</a>
<hr>
{% render_table table %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,88 @@
{% extends "wei/base.html" %}
{% load i18n %}
{% block profile_content %}
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Bus attribution" %}</h3>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-xl-6">{% trans 'user'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.user }}</dd>
<dt class="col-xl-6">{% trans 'last name'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.user.last_name }}</dd>
<dt class="col-xl-6">{% trans 'first name'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.user.first_name }}</dd>
<dt class="col-xl-6">{% trans 'gender'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.get_gender_display }}</dd>
<dt class="col-xl-6">{% trans 'department'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.user.profile.get_department_display }}</dd>
<dt class="col-xl-6">{% trans 'health issues'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.health_issues|default:"—" }}</dd>
<dt class="col-xl-6">{% trans 'suggested bus'|capfirst %}</dt>
<dd class="col-xl-6">{{ survey.information.selected_bus_name }}</dd>
</dl>
<div class="card">
<div class="card-header">
<button class="btn btn-link" data-toggle="collapse" data-target="#raw-survey">{% trans "View raw survey information" %}</button>
</div>
<div class="collapse" id="raw-survey">
<dl class="row">
{% for key, value in survey.registration.information.items %}
<dt class="col-xl-6">{{ key }}</dt>
<dd class="col-xl-6">{{ value }}</dd>
{% endfor %}
</dl>
</div>
</div>
<hr>
{% for bus, score in survey.ordered_buses %}
<button class="btn btn-{% if bus.pk == survey.information.selected_bus_pk %}success{% else %}light{% endif %}" onclick="choose_bus({{ bus.pk }})">
{{ bus }} ({{ score|floatformat:2 }}) : {{ bus.memberships.count }}+{{ bus.suggested_first_year }} / {{ bus.size }}
</button>
{% endfor %}
<a href="{% url 'wei:wei_1A_list' pk=object.wei.pk %}" class="btn btn-block btn-info">{% trans "Back to main list" %}</a>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
function choose_bus(bus_id) {
let valid_buses = [{% for bus, score in survey.ordered_buses %}{{ bus.pk }}, {% endfor %}];
if (valid_buses.indexOf(bus_id) === -1) {
console.log("Invalid chosen bus")
return
}
$.ajax({
url: "/api/wei/membership/{{ object.membership.id }}/",
type: "PATCH",
dataType: "json",
headers: {
"X-CSRFTOKEN": CSRF_TOKEN
},
data: {
bus: bus_id,
}
}).done(function () {
window.location = "{% url 'wei:wei_bus_1A_next' pk=object.wei.pk %}"
}).fail(function (xhr) {
errMsg(xhr.responseJSON)
})
}
</script>
{% endblock %}

View File

@ -94,6 +94,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if can_validate_1a %}
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
{% endif %}
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}

View File

@ -2,6 +2,7 @@
\usepackage{fontspec} \usepackage{fontspec}
\usepackage[margin=1.5cm]{geometry} \usepackage[margin=1.5cm]{geometry}
\usepackage{longtable}
\begin{document} \begin{document}
\begin{center} \begin{center}
@ -19,7 +20,7 @@
\begin{center} \begin{center}
\footnotesize \footnotesize
\begin{tabular}{ccccccccc} \begin{longtable}{ccccccccc}
\textbf{Nom} & \textbf{Prénom} & \textbf{Date de naissance} & \textbf{Genre} & \textbf{Section} \textbf{Nom} & \textbf{Prénom} & \textbf{Date de naissance} & \textbf{Genre} & \textbf{Section}
& \textbf{Bus} & \textbf{Équipe} & \textbf{Rôles} \\ & \textbf{Bus} & \textbf{Équipe} & \textbf{Rôles} \\
{% for membership in memberships %} {% for membership in memberships %}
@ -27,20 +28,20 @@
& {{ membership.registration.get_gender_display|safe }} & {{ membership.user.profile.section_generated|safe }} & {{ membership.bus.name|safe }} & {{ membership.registration.get_gender_display|safe }} & {{ membership.user.profile.section_generated|safe }} & {{ membership.bus.name|safe }}
& {% if membership.team %}{{ membership.team.name|safe }}{% else %}--{% endif %} & {{ membership.roles.first|safe }} \\ & {% if membership.team %}{{ membership.team.name|safe }}{% else %}--{% endif %} & {{ membership.roles.first|safe }} \\
{% endfor %} {% endfor %}
\end{tabular} \end{longtable}
\end{center} \end{center}
\footnotesize \footnotesize
Section = Année à l'ENS + code du département Section = Année à l'ENS + code du département
\begin{center} \begin{center}
\begin{tabular}{ccccccccc} \begin{longtable}{ccccccccc}
\textbf{Code} & A0 & A1 & A2 & A'2 & A''2 & A3 & B1234 & B1 \\ \textbf{Code} & A0 & A1 & A2 & A'2 & A''2 & A3 & B1234 & B1 \\
\textbf{Département} & Informatique & Maths & Physique & Physique appliquée & Chimie & Biologie & SAPHIRE & Mécanique \\ \textbf{Département} & Informatique & Maths & Physique & Physique appliquée & Chimie & Biologie & SAPHIRE & Mécanique \\
\hline \hline
\textbf{Code} & B2 & B3 & B4 & C & D2 & D3 & E & EXT \\ \textbf{Code} & B2 & B3 & B4 & C & D2 & D3 & E & EXT \\
\textbf{Département} & Génie civil & Génie mécanique & EEA & Design & Éco-gestion & Sciences sociales & Anglais & Extérieur \textbf{Département} & Génie civil & Génie mécanique & EEA & Design & Éco-gestion & Sciences sociales & Anglais & Extérieur
\end{tabular} \end{longtable}
\end{center} \end{center}
\end{document} \end{document}

View File

@ -53,7 +53,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<dd class="col-xl-6">{{ registration.first_year|yesno }}</dd> <dd class="col-xl-6">{{ registration.first_year|yesno }}</dd>
<dt class="col-xl-6">{% trans 'gender'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'gender'|capfirst %}</dt>
<dd class="col-xl-6">{{ registration.gender }}</dd> <dd class="col-xl-6">{{ registration.get_gender_display }}</dd>
<dt class="col-xl-6">{% trans 'clothing cut'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'clothing cut'|capfirst %}</dt>
<dd class="col-xl-6">{{ registration.clothing_cut }}</dd> <dd class="col-xl-6">{{ registration.clothing_cut }}</dd>

View File

@ -0,0 +1,110 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import random
from django.contrib.auth.models import User
from django.test import TestCase
from ..forms.surveys.wei2021 import WEIBusInformation2021, WEISurvey2021, WORDS, WEISurveyInformation2021
from ..models import Bus, WEIClub, WEIRegistration
class TestWEIAlgorithm(TestCase):
"""
Run some tests to ensure that the WEI algorithm is working well.
"""
fixtures = ('initial',)
def setUp(self):
"""
Create some test data, with one WEI and 10 buses with random score attributions.
"""
self.wei = WEIClub.objects.create(
name="WEI 2021",
email="wei2021@example.com",
date_start='2021-09-17',
date_end='2021-09-19',
year=2021,
)
self.buses = []
for i in range(10):
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
self.buses.append(bus)
information = WEIBusInformation2021(bus)
for word in WORDS:
information.scores[word] = random.randint(0, 101)
information.save()
bus.save()
def test_survey_algorithm_small(self):
"""
There are only a few people in each bus, ensure that each person has its best bus
"""
# Add a few users
for i in range(10):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2021(registration)
for j in range(1, 21):
setattr(information, f'word{j}', random.choice(WORDS))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2021.get_algorithm_class()().run_algorithm()
# Ensure that everyone has its first choice
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2021(r)
preferred_bus = survey.ordered_buses()[0][0]
chosen_bus = survey.information.get_selected_bus()
self.assertEqual(preferred_bus, chosen_bus)
def test_survey_algorithm_full(self):
"""
Buses are full of first year people, ensure that they are happy
"""
# Add a lot of users
for i in range(95):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2021(registration)
for j in range(1, 21):
setattr(information, f'word{j}', random.choice(WORDS))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2021.get_algorithm_class()().run_algorithm()
penalty = 0
# Ensure that everyone seems to be happy
# We attribute a penalty for each user that didn't have its first choice
# The penalty is the square of the distance between the score of the preferred bus
# and the score of the attributed bus
# We consider it acceptable if the mean of this distance is lower than 5 %
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2021(r)
chosen_bus = survey.information.get_selected_bus()
buses = survey.ordered_buses()
score = min(v for bus, v in buses if bus == chosen_bus)
max_score = buses[0][1]
penalty += (max_score - score) ** 2
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %

View File

@ -0,0 +1,110 @@
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import random
from django.contrib.auth.models import User
from django.test import TestCase
from ..forms.surveys.wei2022 import WEIBusInformation2022, WEISurvey2022, WORDS, WEISurveyInformation2022
from ..models import Bus, WEIClub, WEIRegistration
class TestWEIAlgorithm(TestCase):
"""
Run some tests to ensure that the WEI algorithm is working well.
"""
fixtures = ('initial',)
def setUp(self):
"""
Create some test data, with one WEI and 10 buses with random score attributions.
"""
self.wei = WEIClub.objects.create(
name="WEI 2022",
email="wei2022@example.com",
date_start='2022-09-16',
date_end='2022-09-18',
year=2022,
)
self.buses = []
for i in range(10):
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
self.buses.append(bus)
information = WEIBusInformation2022(bus)
for word in WORDS:
information.scores[word] = random.randint(0, 101)
information.save()
bus.save()
def test_survey_algorithm_small(self):
"""
There are only a few people in each bus, ensure that each person has its best bus
"""
# Add a few users
for i in range(10):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2022(registration)
for j in range(1, 21):
setattr(information, f'word{j}', random.choice(WORDS))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2022.get_algorithm_class()().run_algorithm()
# Ensure that everyone has its first choice
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2022(r)
preferred_bus = survey.ordered_buses()[0][0]
chosen_bus = survey.information.get_selected_bus()
self.assertEqual(preferred_bus, chosen_bus)
def test_survey_algorithm_full(self):
"""
Buses are full of first year people, ensure that they are happy
"""
# Add a lot of users
for i in range(95):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2022(registration)
for j in range(1, 21):
setattr(information, f'word{j}', random.choice(WORDS))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2022.get_algorithm_class()().run_algorithm()
penalty = 0
# Ensure that everyone seems to be happy
# We attribute a penalty for each user that didn't have its first choice
# The penalty is the square of the distance between the score of the preferred bus
# and the score of the attributed bus
# We consider it acceptable if the mean of this distance is lower than 5 %
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2022(r)
chosen_bus = survey.information.get_selected_bus()
buses = survey.ordered_buses()
score = min(v for bus, v in buses if bus == chosen_bus)
max_score = buses[0][1]
penalty += (max_score - score) ** 2
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %

View File

@ -12,7 +12,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from member.models import Membership, Club from member.models import Membership, Club
from note.models import NoteClub, SpecialTransaction from note.models import NoteClub, SpecialTransaction, NoteUser
from treasury.models import SogeCredit from treasury.models import SogeCredit
from ..api.views import BusViewSet, BusTeamViewSet, WEIClubViewSet, WEIMembershipViewSet, WEIRegistrationViewSet, \ from ..api.views import BusViewSet, BusTeamViewSet, WEIClubViewSet, WEIMembershipViewSet, WEIRegistrationViewSet, \
@ -84,6 +84,13 @@ class TestWEIRegistration(TestCase):
wei=self.wei, wei=self.wei,
description="Test Bus", description="Test Bus",
) )
# Setup the bus
bus_info = CurrentSurvey.get_algorithm_class().get_bus_information(self.bus)
bus_info.scores["Jus de fruit"] = 70
bus_info.save()
self.bus.save()
self.team = BusTeam.objects.create( self.team = BusTeam.objects.create(
name="Test Team", name="Test Team",
bus=self.bus, bus=self.bus,
@ -188,7 +195,9 @@ class TestWEIRegistration(TestCase):
response = self.client.post(reverse("wei:add_bus", kwargs=dict(pk=self.wei.pk)), dict( response = self.client.post(reverse("wei:add_bus", kwargs=dict(pk=self.wei.pk)), dict(
wei=self.wei.id, wei=self.wei.id,
name="Create Bus Test", name="Create Bus Test",
size=50,
description="This bus was created.", description="This bus was created.",
information_json="{}",
)) ))
qs = Bus.objects.filter(name="Create Bus Test") qs = Bus.objects.filter(name="Create Bus Test")
self.assertTrue(qs.exists()) self.assertTrue(qs.exists())
@ -218,7 +227,9 @@ class TestWEIRegistration(TestCase):
response = self.client.post(reverse("wei:update_bus", kwargs=dict(pk=self.bus.pk)), dict( response = self.client.post(reverse("wei:update_bus", kwargs=dict(pk=self.bus.pk)), dict(
name="Update Bus Test", name="Update Bus Test",
size=40,
description="This bus was updated.", description="This bus was updated.",
information_json="{}",
)) ))
qs = Bus.objects.filter(name="Update Bus Test", id=self.bus.id) qs = Bus.objects.filter(name="Update Bus Test", id=self.bus.id)
self.assertRedirects(response, reverse("wei:manage_bus", kwargs=dict(pk=self.bus.pk)), 302, 200) self.assertRedirects(response, reverse("wei:manage_bus", kwargs=dict(pk=self.bus.pk)), 302, 200)
@ -291,6 +302,7 @@ class TestWEIRegistration(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
user = User.objects.create(username="toto", email="toto@example.com") user = User.objects.create(username="toto", email="toto@example.com")
NoteUser.objects.create(user=user)
# Try with an invalid form # Try with an invalid form
response = self.client.post(reverse("wei:wei_register_2A", kwargs=dict(wei_pk=self.wei.pk)), dict( response = self.client.post(reverse("wei:wei_register_2A", kwargs=dict(wei_pk=self.wei.pk)), dict(
@ -357,7 +369,7 @@ class TestWEIRegistration(TestCase):
last_name="toto", last_name="toto",
bank="Société générale", bank="Société générale",
)) ))
response = self.client.get(reverse("wei:wei_register_2A_myself", kwargs=dict(wei_pk=self.wei.pk))) response = self.client.get(reverse("wei:wei_register_2A", kwargs=dict(wei_pk=self.wei.pk)))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Check that if the WEI is started, we can't register anyone # Check that if the WEI is started, we can't register anyone
@ -373,10 +385,8 @@ class TestWEIRegistration(TestCase):
response = self.client.get(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk))) response = self.client.get(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("wei:wei_register_1A_myself", kwargs=dict(wei_pk=self.wei.pk)))
self.assertEqual(response.status_code, 200)
user = User.objects.create(username="toto", email="toto@example.com") user = User.objects.create(username="toto", email="toto@example.com")
NoteUser.objects.create(user=user)
response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict( response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict(
user=user.id, user=user.id,
soge_credit=True, soge_credit=True,
@ -456,6 +466,24 @@ class TestWEIRegistration(TestCase):
response = self.client.get(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk))) response = self.client.get(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)))
self.assertRedirects(response, reverse("wei:wei_closed", kwargs=dict(pk=self.wei.pk)), 302, 200) self.assertRedirects(response, reverse("wei:wei_closed", kwargs=dict(pk=self.wei.pk)), 302, 200)
def test_register_myself(self):
"""
Try to register myself to the WEI, and check redirections.
"""
response = self.client.get(reverse('wei:wei_register_1A_myself', args=(self.wei.pk,)))
self.assertRedirects(response, reverse('wei:wei_update_registration', args=(self.registration.pk,)))
response = self.client.get(reverse('wei:wei_register_2A_myself', args=(self.wei.pk,)))
self.assertRedirects(response, reverse('wei:wei_update_registration', args=(self.registration.pk,)))
self.registration.delete()
response = self.client.get(reverse('wei:wei_register_1A_myself', args=(self.wei.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('wei:wei_register_2A_myself', args=(self.wei.pk,)))
self.assertEqual(response.status_code, 200)
def test_wei_survey_ended(self): def test_wei_survey_ended(self):
""" """
Test display the end page of a survey. Test display the end page of a survey.
@ -754,62 +782,7 @@ class TestDefaultWEISurvey(TestCase):
WEISurvey.update_form(None, None) WEISurvey.update_form(None, None)
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey) self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
self.assertEqual(CurrentSurvey.get_year(), 2021) self.assertEqual(CurrentSurvey.get_year(), 2022)
class TestWEISurveyAlgorithm(TestCase):
"""
Run the WEI Algorithm.
TODO: Improve this test with some test data once the algorithm will be implemented.
"""
fixtures = ("initial",)
def setUp(self) -> None:
self.year = timezone.now().year
self.wei = WEIClub.objects.create(
name="Test WEI",
email="gc.wei@example.com",
parent_club_id=2,
membership_fee_paid=12500,
membership_fee_unpaid=5500,
membership_start=date(self.year, 1, 1),
membership_end=date(self.year, 12, 31),
year=self.year,
date_start=date.today() + timedelta(days=2),
date_end=date(self.year, 12, 31),
)
NoteClub.objects.create(club=self.wei)
self.bus = Bus.objects.create(
name="Test Bus",
wei=self.wei,
description="Test Bus",
)
self.team = BusTeam.objects.create(
name="Test Team",
bus=self.bus,
color=0xFFFFFF,
description="Test Team",
)
self.user = User.objects.create(username="toto")
self.registration = WEIRegistration.objects.create(
user_id=self.user.id,
wei_id=self.wei.id,
soge_credit=True,
caution_check=True,
birth_date=date(2000, 1, 1),
gender="nonbinary",
clothing_cut="male",
clothing_size="XL",
health_issues="I am a bot",
emergency_contact_name="Pikachu",
emergency_contact_phone="+33123456789",
first_year=True,
)
CurrentSurvey(self.registration).save()
def test_survey_algorithm(self):
CurrentSurvey.get_algorithm_class()().run_algorithm()
class TestWeiAPI(TestAPI): class TestWeiAPI(TestAPI):

View File

@ -3,12 +3,11 @@
from django.urls import path from django.urls import path
from .views import CurrentWEIDetailView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView,\ from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView,\ WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView,\ BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, WEIDeleteRegistrationView,\ WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
app_name = 'wei' app_name = 'wei'
urlpatterns = [ urlpatterns = [
@ -24,6 +23,7 @@ urlpatterns = [
name="wei_memberships_bus_pdf"), name="wei_memberships_bus_pdf"),
path('detail/<int:wei_pk>/memberships/pdf/<int:bus_pk>/<int:team_pk>/', MemberListRenderView.as_view(), path('detail/<int:wei_pk>/memberships/pdf/<int:bus_pk>/<int:team_pk>/', MemberListRenderView.as_view(),
name="wei_memberships_team_pdf"), name="wei_memberships_team_pdf"),
path('bus-1A/list/<int:pk>/', WEI1AListView.as_view(), name="wei_1A_list"),
path('add-bus/<int:pk>/', BusCreateView.as_view(), name="add_bus"), path('add-bus/<int:pk>/', BusCreateView.as_view(), name="add_bus"),
path('manage-bus/<int:pk>/', BusManageView.as_view(), name="manage_bus"), path('manage-bus/<int:pk>/', BusManageView.as_view(), name="manage_bus"),
path('update-bus/<int:pk>/', BusUpdateView.as_view(), name="update_bus"), path('update-bus/<int:pk>/', BusUpdateView.as_view(), name="update_bus"),
@ -40,4 +40,6 @@ urlpatterns = [
path('survey/<int:pk>/', WEISurveyView.as_view(), name="wei_survey"), path('survey/<int:pk>/', WEISurveyView.as_view(), name="wei_survey"),
path('survey/<int:pk>/end/', WEISurveyEndView.as_view(), name="wei_survey_end"), path('survey/<int:pk>/end/', WEISurveyEndView.as_view(), name="wei_survey_end"),
path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"), path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"),
path('bus-1A/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
] ]

View File

@ -7,14 +7,14 @@ import subprocess
from datetime import date, timedelta from datetime import date, timedelta
from tempfile import mkdtemp from tempfile import mkdtemp
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q, Count from django.db.models import Q, Count
from django.db.models.functions.text import Lower from django.db.models.functions.text import Lower
from django.forms import HiddenInput from django.http import HttpResponse, Http404
from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -32,8 +32,10 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms.registration import WEIChooseBusForm from .forms.registration import WEIChooseBusForm
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembershipForm, CurrentSurvey from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \
from .tables import WEITable, WEIRegistrationTable, BusTable, BusTeamTable, WEIMembershipTable WEIMembershipForm, CurrentSurvey
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
WEIRegistration1ATable, WEIMembershipTable
class CurrentWEIDetailView(LoginRequiredMixin, RedirectView): class CurrentWEIDetailView(LoginRequiredMixin, RedirectView):
@ -57,7 +59,7 @@ class WEIListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["can_create_wei"] = PermissionBackend.check_perm(self.request.user, "wei.add_weiclub", WEIClub( context["can_create_wei"] = PermissionBackend.check_perm(self.request, "wei.add_weiclub", WEIClub(
name="", name="",
email="weiclub@example.com", email="weiclub@example.com",
year=0, year=0,
@ -112,7 +114,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
club = context["club"] club = context["club"]
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \ club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")) \ .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \
.order_by('-created_at', '-id') .order_by('-created_at', '-id')
history_table = HistoryTable(club_transactions, prefix="history-") history_table = HistoryTable(club_transactions, prefix="history-")
history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1)) history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
@ -121,18 +123,18 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
club_member = WEIMembership.objects.filter( club_member = WEIMembership.objects.filter(
club=club, club=club,
date_end__gte=date.today(), date_end__gte=date.today(),
).filter(PermissionBackend.filter_queryset(self.request.user, WEIMembership, "view")) ).filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view"))
membership_table = WEIMembershipTable(data=club_member, prefix="membership-") membership_table = WEIMembershipTable(data=club_member, prefix="membership-")
membership_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1)) membership_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1))
context['member_list'] = membership_table context['member_list'] = membership_table
pre_registrations = WEIRegistration.objects.filter( pre_registrations = WEIRegistration.objects.filter(
PermissionBackend.filter_queryset(self.request.user, WEIRegistration, "view")).filter( PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter(
membership=None, membership=None,
wei=club wei=club
) )
pre_registrations_table = WEIRegistrationTable(data=pre_registrations, prefix="pre-registration-") pre_registrations_table = WEIRegistrationTable(data=pre_registrations, prefix="pre-registration-")
pre_registrations_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1)) pre_registrations_table.paginate(per_page=20, page=self.request.GET.get('pre-registration-page', 1))
context['pre_registrations'] = pre_registrations_table context['pre_registrations'] = pre_registrations_table
my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user) my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user)
@ -142,7 +144,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
my_registration = None my_registration = None
context["my_registration"] = my_registration context["my_registration"] = my_registration
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request.user, Bus, "view")) \ buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name") .filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
bus_table = BusTable(data=buses, prefix="bus-") bus_table = BusTable(data=buses, prefix="bus-")
context['buses'] = bus_table context['buses'] = bus_table
@ -167,7 +169,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
emergency_contact_phone="No", emergency_contact_phone="No",
) )
context["can_add_first_year_member"] = PermissionBackend \ context["can_add_first_year_member"] = PermissionBackend \
.check_perm(self.request.user, "wei.add_weiregistration", empty_fy_registration) .check_perm(self.request, "wei.add_weiregistration", empty_fy_registration)
# Check if the user has the right to create a registration of a random old member. # Check if the user has the right to create a registration of a random old member.
empty_old_registration = WEIRegistration( empty_old_registration = WEIRegistration(
@ -180,16 +182,20 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
emergency_contact_phone="No", emergency_contact_phone="No",
) )
context["can_add_any_member"] = PermissionBackend \ context["can_add_any_member"] = PermissionBackend \
.check_perm(self.request.user, "wei.add_weiregistration", empty_old_registration) .check_perm(self.request, "wei.add_weiregistration", empty_old_registration)
empty_bus = Bus( empty_bus = Bus(
wei=club, wei=club,
name="", name="",
) )
context["can_add_bus"] = PermissionBackend.check_perm(self.request.user, "wei.add_bus", empty_bus) context["can_add_bus"] = PermissionBackend.check_perm(self.request, "wei.add_bus", empty_bus)
context["not_first_year"] = WEIMembership.objects.filter(user=self.request.user).exists() context["not_first_year"] = WEIMembership.objects.filter(user=self.request.user).exists()
qs = WEIMembership.objects.filter(club=club, registration__first_year=True, bus__isnull=True)
context["can_validate_1a"] = PermissionBackend.check_perm(
self.request, "wei.change_weimembership_bus", qs.first()) if qs.exists() else False
return context return context
@ -222,7 +228,7 @@ class WEIMembershipsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
| Q(team__name__iregex=pattern) | Q(team__name__iregex=pattern)
) )
return qs[:20] return qs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -256,7 +262,7 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) | Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
) )
return qs[:20] return qs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -370,13 +376,13 @@ class BusManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["club"] = self.object.wei context["club"] = self.object.wei
bus = self.object bus = self.object
teams = BusTeam.objects.filter(PermissionBackend.filter_queryset(self.request.user, BusTeam, "view")) \ teams = BusTeam.objects.filter(PermissionBackend.filter_queryset(self.request, BusTeam, "view")) \
.filter(bus=bus).annotate(count=Count("memberships")).order_by("name") .filter(bus=bus).annotate(count=Count("memberships")).order_by("name")
teams_table = BusTeamTable(data=teams, prefix="team-") teams_table = BusTeamTable(data=teams, prefix="team-")
context["teams"] = teams_table context["teams"] = teams_table
memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset( memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset(
self.request.user, WEIMembership, "view")).filter(bus=bus) self.request, WEIMembership, "view")).filter(bus=bus)
memberships_table = WEIMembershipTable(data=memberships, prefix="membership-") memberships_table = WEIMembershipTable(data=memberships, prefix="membership-")
memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1)) memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1))
context["memberships"] = memberships_table context["memberships"] = memberships_table
@ -469,7 +475,7 @@ class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["club"] = self.object.bus.wei context["club"] = self.object.bus.wei
memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset( memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset(
self.request.user, WEIMembership, "view")).filter(team=self.object) self.request, WEIMembership, "view")).filter(team=self.object)
memberships_table = WEIMembershipTable(data=memberships, prefix="membership-") memberships_table = WEIMembershipTable(data=memberships, prefix="membership-")
memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1)) memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1))
context["memberships"] = memberships_table context["memberships"] = memberships_table
@ -487,9 +493,16 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
def get_sample_object(self): def get_sample_object(self):
wei = WEIClub.objects.get(pk=self.kwargs["wei_pk"]) wei = WEIClub.objects.get(pk=self.kwargs["wei_pk"])
if "myself" in self.request.path:
user = self.request.user
else:
# To avoid unique validation issues, we use an account that can't join the WEI.
# In development mode, the note account may not exist, we use a random user (may fail)
user = User.objects.get(username="note") \
if User.objects.filter(username="note").exists() else User.objects.first()
return WEIRegistration( return WEIRegistration(
wei=wei, wei=wei,
user=self.request.user, user=user,
first_year=True, first_year=True,
birth_date="1970-01-01", birth_date="1970-01-01",
gender="No", gender="No",
@ -503,6 +516,11 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
# We can't register someone once the WEI is started and before the membership start date # We can't register someone once the WEI is started and before the membership start date
if today >= wei.date_start or today < wei.membership_start: if today >= wei.date_start or today < wei.membership_start:
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,))) return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
# Don't register twice
if 'myself' in self.request.path and not self.request.user.is_anonymous \
and WEIRegistration.objects.filter(wei=wei, user=self.request.user).exists():
obj = WEIRegistration.objects.get(wei=wei, user=self.request.user)
return redirect(reverse_lazy('wei:wei_update_registration', args=(obj.pk,)))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -538,6 +556,12 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
" participated to a WEI.")) " participated to a WEI."))
return self.form_invalid(form) return self.form_invalid(form)
if 'treasury' in settings.INSTALLED_APPS:
from treasury.models import SogeCredit
form.instance.soge_credit = \
form.instance.soge_credit \
or SogeCredit.objects.filter(user=form.instance.user, credit_transaction__valid=False).exists()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
@ -555,9 +579,16 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
def get_sample_object(self): def get_sample_object(self):
wei = WEIClub.objects.get(pk=self.kwargs["wei_pk"]) wei = WEIClub.objects.get(pk=self.kwargs["wei_pk"])
if "myself" in self.request.path:
user = self.request.user
else:
# To avoid unique validation issues, we use an account that can't join the WEI.
# In development mode, the note account may not exist, we use a random user (may fail)
user = User.objects.get(username="note") \
if User.objects.filter(username="note").exists() else User.objects.first()
return WEIRegistration( return WEIRegistration(
wei=wei, wei=wei,
user=self.request.user, user=user,
first_year=True, first_year=True,
birth_date="1970-01-01", birth_date="1970-01-01",
gender="No", gender="No",
@ -571,6 +602,11 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
# We can't register someone once the WEI is started and before the membership start date # We can't register someone once the WEI is started and before the membership start date
if today >= wei.date_start or today < wei.membership_start: if today >= wei.date_start or today < wei.membership_start:
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,))) return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
# Don't register twice
if 'myself' in self.request.path and not self.request.user.is_anonymous \
and WEIRegistration.objects.filter(wei=wei, user=self.request.user).exists():
obj = WEIRegistration.objects.get(wei=wei, user=self.request.user)
return redirect(reverse_lazy('wei:wei_update_registration', args=(obj.pk,)))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -627,6 +663,12 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
form.instance.information = information form.instance.information = information
form.instance.save() form.instance.save()
if 'treasury' in settings.INSTALLED_APPS:
from treasury.models import SogeCredit
form.instance.soge_credit = \
form.instance.soge_credit \
or SogeCredit.objects.filter(user=form.instance.user, credit_transaction__valid=False).exists()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
@ -655,26 +697,19 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
context["club"] = self.object.wei context["club"] = self.object.wei
if self.object.is_validated: if self.object.is_validated:
membership_form = WEIMembershipForm(instance=self.object.membership, membership_form = self.get_membership_form(instance=self.object.membership,
data=self.request.POST if self.request.POST else None) data=self.request.POST)
for field_name, field in membership_form.fields.items():
if not PermissionBackend.check_perm(
self.request.user, "wei.change_membership_" + field_name, self.object.membership):
field.widget = HiddenInput()
del membership_form.fields["credit_type"]
del membership_form.fields["credit_amount"]
del membership_form.fields["first_name"]
del membership_form.fields["last_name"]
del membership_form.fields["bank"]
context["membership_form"] = membership_form context["membership_form"] = membership_form
elif not self.object.first_year and PermissionBackend.check_perm( elif not self.object.first_year and PermissionBackend.check_perm(
self.request.user, "wei.change_weiregistration_information_json", self.object): self.request, "wei.change_weiregistration_information_json", self.object):
information = self.object.information
d = dict(
bus=Bus.objects.filter(pk__in=information["preferred_bus_pk"]).all(),
team=BusTeam.objects.filter(pk__in=information["preferred_team_pk"]).all(),
roles=WEIRole.objects.filter(pk__in=information["preferred_roles_pk"]).all(),
) if 'preferred_bus_pk' in information else dict()
choose_bus_form = WEIChooseBusForm( choose_bus_form = WEIChooseBusForm(
self.request.POST if self.request.POST else dict( self.request.POST if self.request.POST else d
bus=Bus.objects.filter(pk__in=self.object.information["preferred_bus_pk"]).all(),
team=BusTeam.objects.filter(pk__in=self.object.information["preferred_team_pk"]).all(),
roles=WEIRole.objects.filter(pk__in=self.object.information["preferred_roles_pk"]).all(),
)
) )
choose_bus_form.fields["bus"].queryset = Bus.objects.filter(wei=context["club"]) choose_bus_form.fields["bus"].queryset = Bus.objects.filter(wei=context["club"])
choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"]) choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"])
@ -690,21 +725,35 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
form.fields["user"].disabled = True form.fields["user"].disabled = True
if not self.object.first_year: # The auto-json-format may cause issues with the default field remove
if not PermissionBackend.check_perm(self.request, 'wei.change_weiregistration_information_json', self.object):
del form.fields["information_json"] del form.fields["information_json"]
return form return form
def get_membership_form(self, data=None, instance=None):
membership_form = WEIMembershipForm(data if data else None, instance=instance)
del membership_form.fields["credit_type"]
del membership_form.fields["credit_amount"]
del membership_form.fields["first_name"]
del membership_form.fields["last_name"]
del membership_form.fields["bank"]
for field_name, _field in list(membership_form.fields.items()):
if not PermissionBackend.check_perm(
self.request, "wei.change_weimembership_" + field_name, self.object.membership):
del membership_form.fields[field_name]
return membership_form
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
# If the membership is already validated, then we update the bus and the team (and the roles) # If the membership is already validated, then we update the bus and the team (and the roles)
if form.instance.is_validated: if form.instance.is_validated:
membership_form = WEIMembershipForm(self.request.POST, instance=form.instance.membership) membership_form = self.get_membership_form(self.request.POST, form.instance.membership)
if not membership_form.is_valid(): if not membership_form.is_valid():
return self.form_invalid(form) return self.form_invalid(form)
membership_form.save() membership_form.save()
# If it is not validated and if this is an old member, then we update the choices # If it is not validated and if this is an old member, then we update the choices
elif not form.instance.first_year and PermissionBackend.check_perm( elif not form.instance.first_year and PermissionBackend.check_perm(
self.request.user, "wei.change_weiregistration_information_json", self.object): self.request, "wei.change_weiregistration_information_json", self.object):
choose_bus_form = WEIChooseBusForm(self.request.POST) choose_bus_form = WEIChooseBusForm(self.request.POST)
if not choose_bus_form.is_valid(): if not choose_bus_form.is_valid():
return self.form_invalid(form) return self.form_invalid(form)
@ -726,7 +775,7 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
survey = CurrentSurvey(self.object) survey = CurrentSurvey(self.object)
if not survey.is_complete(): if not survey.is_complete():
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk}) return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
if PermissionBackend.check_perm(self.request.user, "wei.add_weimembership", WEIMembership( if PermissionBackend.check_perm(self.request, "wei.add_weimembership", WEIMembership(
club=self.object.wei, club=self.object.wei,
user=self.object.user, user=self.object.user,
date_start=date.today(), date_start=date.today(),
@ -753,7 +802,7 @@ class WEIDeleteRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Delete
if today > wei.membership_end: if today > wei.membership_end:
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,))) return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
if not PermissionBackend.check_perm(self.request.user, "wei.delete_weiregistration", object): if not PermissionBackend.check_perm(self.request, "wei.delete_weiregistration", object):
raise PermissionDenied(_("You don't have the right to delete this WEI registration.")) raise PermissionDenied(_("You don't have the right to delete this WEI registration."))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -772,7 +821,6 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
Validate WEI Registration Validate WEI Registration
""" """
model = WEIMembership model = WEIMembership
form_class = WEIMembershipForm
extra_context = {"title": _("Validate WEI registration")} extra_context = {"title": _("Validate WEI registration")}
def get_sample_object(self): def get_sample_object(self):
@ -818,25 +866,22 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
date_start__gte=bde.membership_start, date_start__gte=bde.membership_start,
).exists() ).exists()
fee = registration.wei.membership_fee_paid if registration.user.profile.paid \ context["fee"] = registration.fee
else registration.wei.membership_fee_unpaid
if not context["kfet_member"]:
fee += kfet.membership_fee_paid if registration.user.profile.paid \
else kfet.membership_fee_unpaid
if not context["bde_member"]:
fee += bde.membership_fee_paid if registration.user.profile.paid \
else bde.membership_fee_unpaid
context["fee"] = fee
form = context["form"] form = context["form"]
if registration.soge_credit: if registration.soge_credit:
form.fields["credit_amount"].initial = fee form.fields["credit_amount"].initial = registration.fee
else: else:
form.fields["credit_amount"].initial = max(0, fee - registration.user.note.balance) form.fields["credit_amount"].initial = max(0, registration.fee - registration.user.note.balance)
return context return context
def get_form_class(self):
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
if registration.first_year and 'sleected_bus_pk' not in registration.information:
return WEIMembership1AForm
return WEIMembershipForm
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"]) registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
@ -852,6 +897,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form.fields["bank"].disabled = True form.fields["bank"].disabled = True
form.fields["bank"].initial = "Société générale" form.fields["bank"].initial = "Société générale"
if 'bus' in form.fields:
# For 2A+ and hardcoded 1A
form.fields["bus"].widget.attrs["api_url"] = "/api/wei/bus/?wei=" + str(registration.wei.pk) form.fields["bus"].widget.attrs["api_url"] = "/api/wei/bus/?wei=" + str(registration.wei.pk)
if registration.first_year: if registration.first_year:
# Use the results of the survey to fill initial data # Use the results of the survey to fill initial data
@ -959,12 +1006,11 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
membership.roles.set(WEIRole.objects.filter(name="1A").all()) membership.roles.set(WEIRole.objects.filter(name="1A").all())
membership.save() membership.save()
ret = super().form_valid(form) membership.save()
membership.refresh_from_db() membership.refresh_from_db()
membership.roles.add(WEIRole.objects.get(name="Adhérent WEI")) membership.roles.add(WEIRole.objects.get(name="Adhérent WEI"))
return ret return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
self.object.refresh_from_db() self.object.refresh_from_db()
@ -1058,7 +1104,7 @@ class MemberListRenderView(LoginRequiredMixin, View):
""" """
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = WEIMembership.objects.filter(PermissionBackend.filter_queryset(self.request.user, WEIMembership, "view")) qs = WEIMembership.objects.filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view"))
qs = qs.filter(club__pk=self.kwargs["wei_pk"]).order_by( qs = qs.filter(club__pk=self.kwargs["wei_pk"]).order_by(
Lower('bus__name'), Lower('bus__name'),
Lower('team__name'), Lower('team__name'),
@ -1131,3 +1177,65 @@ class MemberListRenderView(LoginRequiredMixin, View):
shutil.rmtree(tmp_dir) shutil.rmtree(tmp_dir)
return response return response
class WEI1AListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableView):
model = WEIRegistration
template_name = "wei/1A_list.html"
table_class = WEIRegistration1ATable
extra_context = {"title": _("Attribute buses to first year members")}
def dispatch(self, request, *args, **kwargs):
self.club = WEIClub.objects.get(pk=self.kwargs["pk"])
return super().dispatch(request, *args, **kwargs)
def get_queryset(self, filter_permissions=True, **kwargs):
qs = super().get_queryset(filter_permissions, **kwargs)
qs = qs.filter(first_year=True, membership__isnull=False)
qs = qs.order_by('-membership__bus')
return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['club'] = self.club
context['bus_repartition_table'] = BusRepartitionTable(
Bus.objects.filter(wei=self.club, size__gt=0)
.filter(PermissionBackend.filter_queryset(self.request, Bus, "view"))
.all())
return context
class WEIAttributeBus1AView(ProtectQuerysetMixin, DetailView):
model = WEIRegistration
template_name = "wei/attribute_bus_1A.html"
extra_context = {"title": _("Attribute bus")}
def get_queryset(self, filter_permissions=True, **kwargs):
qs = super().get_queryset(filter_permissions, **kwargs)
qs = qs.filter(first_year=True)
return qs
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
if 'selected_bus_pk' not in obj.information:
return redirect(reverse_lazy('wei:wei_survey', args=(obj.pk,)))
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['club'] = self.object.wei
context['survey'] = CurrentSurvey(self.object)
return context
class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
wei = WEIClub.objects.filter(pk=self.kwargs['pk'])
if not wei.exists():
raise Http404
wei = wei.get()
qs = WEIRegistration.objects.filter(wei=wei, membership__isnull=False, membership__bus__isnull=True)
qs = qs.filter(information_json__contains='selected_bus_pk') # not perfect, but works...
if qs.exists():
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk, ))
return reverse_lazy('wei:wei_1A_list', args=(wei.pk, ))

View File

@ -86,7 +86,7 @@ Génération
Les factures peuvent s'exporter au format PDF (là est tout leur intérêt). Pour cela, on utilise le template LaTeX Les factures peuvent s'exporter au format PDF (là est tout leur intérêt). Pour cela, on utilise le template LaTeX
présent à l'adresse suivante : présent à l'adresse suivante :
`/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/master/templates/treasury/invoice_sample.tex>`_ `/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/main/templates/treasury/invoice_sample.tex>`_
On le remplit avec les données de la facture et les données du BDE, hard-codées. On copie le template rempli dans un On le remplit avec les données de la facture et les données du BDE, hard-codées. On copie le template rempli dans un
ficher tex dans un dossier temporaire. On fait ensuite 2 appels à ``pdflatex`` pour générer la facture au format PDF. ficher tex dans un dossier temporaire. On fait ensuite 2 appels à ``pdflatex`` pour générer la facture au format PDF.

View File

@ -5,19 +5,10 @@ L'authentification `OAuth2 <https://fr.wikipedia.org/wiki/OAuth>`_ est supporté
Note Kfet. Elle offre l'avantage non seulement d'identifier les utilisateurs, mais aussi Note Kfet. Elle offre l'avantage non seulement d'identifier les utilisateurs, mais aussi
de transmettre des informations à un service tiers tels que des informations personnelles, de transmettre des informations à un service tiers tels que des informations personnelles,
le solde de la note ou encore les adhésions de l'utilisateur, en l'avertissant sur le solde de la note ou encore les adhésions de l'utilisateur, en l'avertissant sur
quelles données sont effectivement collectées. quelles données sont effectivement collectées. Ainsi, il est possible de développer des
appplications tierces qui peuvent se baser sur les données de la Note Kfet ou encore
faire des transactions.
.. danger::
L'implémentation actuelle ne permet pas de choisir quels droits on offre. Se connecter
par OAuth2 offre actuellement exactement les mêmes permissions que l'on n'aurait
normalement, avec le masque le plus haut, y compris en écriture.
Faites alors très attention lorsque vous vous connectez à un service tiers via OAuth2,
et contrôlez bien exactement ce que l'application fait de vos données, à savoir si
elle ignore bien tout ce dont elle n'a pas besoin.
À l'avenir, la fenêtre d'authentification pourra vous indiquer clairement quels
paramètres sont collectés.
Configuration du serveur Configuration du serveur
------------------------ ------------------------
@ -44,7 +35,21 @@ l'authentification OAuth2. On adapte alors la configuration pour permettre cela
... ...
} }
On ajoute les routes dans ``urls.py`` : On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions fines :
.. code:: python
OAUTH2_PROVIDER = {
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
}
Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
et de demander des scopes facultatives (voir plus bas).
Un jeton de rafraîchissement expire de plus au bout de 14 jours, si non-renouvelé.
On ajoute enfin les routes dans ``urls.py`` :
.. code:: python .. code:: python
@ -58,8 +63,7 @@ L'OAuth2 est désormais prêt à être utilisé.
Configuration client Configuration client
-------------------- --------------------
Contrairement au `CAS <cas>`_, n'importe qui peut en théorie créer une application OAuth2. Contrairement au `CAS <cas>`_, n'importe qui peut créer une application OAuth2.
En théorie, car pour l'instant les permissions ne leur permettent pas.
Pour créer une application, il faut se rendre à la page Pour créer une application, il faut se rendre à la page
`/o/applications/ <https://note.crans.org/o/applications/>`_. Dans ``client type``, `/o/applications/ <https://note.crans.org/o/applications/>`_. Dans ``client type``,
@ -72,14 +76,51 @@ Il vous suffit de donner à votre application :
* L'identifiant client (client-ID) * L'identifiant client (client-ID)
* La clé secrète * La clé secrète
* Les scopes : sous-ensemble de ``[read, write]`` (ignoré pour l'instant, cf premier paragraphe) * Les scopes, qui peuvent être récupérées sur cette page : `<https://note.crans.org/permission/scopes/>`_
* L'URL d'autorisation : `<https://note.crans.org/o/authorize/>`_ * L'URL d'autorisation : `<https://note.crans.org/o/authorize/>`_
* L'URL d'obtention de jeton : `<https://note.crans.org/o/token/>`_ * L'URL d'obtention de jeton : `<https://note.crans.org/o/token/>`_
* L'URL de récupération des informations de l'utilisateur : `<https://note.crans.org/api/me/>`_ * Si besoin, l'URL de récupération des informations de l'utilisateur : `<https://note.crans.org/api/me/>`_
N'hésitez pas à consulter la page `<https://note.crans.org/api/me/>`_ pour s'imprégner N'hésitez pas à consulter la page `<https://note.crans.org/api/me/>`_ pour s'imprégner
du format renvoyé. du format renvoyé.
.. warning::
Un petit mot sur les scopes : tel qu'implémenté, une scope est une permission unitaire
(telle que décrite dans le modèle ``Permission``) associée à un club. Ainsi, un jeton
a accès à une scope si et seulement si le/la propriétaire du jeton dispose d'une adhésion
courante dans le club lié à la scope qui lui octroie cette permission.
Par exemple, un jeton pourra avoir accès à la permission de créer des transactions en lien
avec un club si et seulement si le propriétaire du jeton est trésorier du club.
La vérification des droits du propriétaire est faite systématiquement, afin de ne pas
faire confiance au jeton en cas de droits révoqués à son propriétaire.
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
jetons.
.. danger::
Demander des scopes n'implique pas de les avoir.
Lorsque des scopes sont demandées par un client, la Note
va considérer l'ensemble des permissions accessibles parmi
ce qui est demandé. Dans vos programmes, vous devrez donc
vérifier les permissions acquises (communiquées lors de la
récupération du jeton d'accès à partir du grant code),
et prévoir un comportement dans le cas où des permissions
sont manquantes.
Cela offre un intérêt supérieur par rapport au protocole
OAuth2 classique, consistant à demander trop de permissions
et agir en conséquence.
Par exemple, vous pourriez demander la permission d'accéder
aux membres d'un club ou de faire des transactions, et agir
uniquement dans le cas où l'utilisateur connecté possède la
permission problématique.
Avec Django-allauth Avec Django-allauth
################### ###################
@ -102,6 +143,7 @@ installées (sur votre propre client), puis de bien ajouter l'application social
SOCIALACCOUNT_PROVIDERS = { SOCIALACCOUNT_PROVIDERS = {
'notekfet': { 'notekfet': {
# 'DOMAIN': 'note.crans.org', # 'DOMAIN': 'note.crans.org',
'SCOPE': ['1_1', '2_1'],
}, },
... ...
} }
@ -109,6 +151,10 @@ installées (sur votre propre client), puis de bien ajouter l'application social
Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il
se connectera à ``note.crans.org`` si vous ne renseignez rien. se connectera à ``note.crans.org`` si vous ne renseignez rien.
Le paramètre ``SCOPE`` permet de définir les scopes à demander.
Dans l'exemple ci-dessous, les permissions d'accéder à l'utilisateur
et au profil sont demandées.
En créant l'application sur la note, vous pouvez renseigner En créant l'application sur la note, vous pouvez renseigner
``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection, ``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection,
à adapter selon votre configuration. à adapter selon votre configuration.
@ -131,3 +177,97 @@ alors autant le faire via un shell python :
Si vous avez bien configuré ``django-allauth``, vous êtes désormais prêts par à vous Si vous avez bien configuré ``django-allauth``, vous êtes désormais prêts par à vous
connecter via la note :) Par défaut, nom, prénom, pseudo et adresse e-mail sont connecter via la note :) Par défaut, nom, prénom, pseudo et adresse e-mail sont
récupérés. Les autres données sont stockées mais inutilisées. récupérés. Les autres données sont stockées mais inutilisées.
Application personnalisée
#########################
Ce modèle vous permet de créer vos propres applications à interfacer avec la Note Kfet.
Commencez par créer une application : `<https://note.crans.org/o/applications/register>`_.
Dans ``Client type``, choisissez ``Confidential`` si des informations confidentielles sont
amenées à transiter, sinon ``public``. Choisissez ``Authorization code`` dans
``Authorization grant type``.
Dans ``Redirect uris``, vous devez insérer l'ensemble des URL autorisées à être redirigées
à la suite d'une autorisation OAuth2. La première URL entrée sera l'URL par défaut dans le
cas où elle n'est pas explicitement indiquée lors de l'autorisation.
.. note::
À des fins de tests, il est possible de laisser `<http://localhost/>`_ pour faire des
appels à la main en récupérant le jeton d'autorisation.
Lorsqu'un client veut s'authentifier via la Note Kfet, il va devoir accéder à une page
d'authentification. La page d'autorisation est `<https://note.crans.org/o/authorize/>`_,
c'est sur cette page qu'il faut rediriger les utilisateurs. Il faut mettre en paramètre GET :
* ``client_id`` : l'identifiant client de l'application (public) ;
* ``response_type`` : mettre ``code`` ;
* ``scope`` : l'ensemble des scopes demandés, séparés par des espaces. Ces scopes peuvent
être récupérés sur la page `<https://note.crans.org/permission/scopes/>`_.
* ``redirect_uri`` : l'URL sur laquelle rediriger qui récupérera le code d'accès. Doit être
autorisée par l'application. À des fins de test, peut être `<http://localhost/>`_.
* ``state`` : optionnel, peut être utilisé pour permettre au client de détecter des requêtes
provenant d'autres sites.
Sur cette page, les permissions demandées seront listées, et l'utilisateur aura le choix
d'accepter ou non. Dans les deux cas, l'utilisateur sera redirigée vers ``redirect_uri``,
avec pour paramètre GET soit le message d'erreur, soit un paramètre ``code`` correspondant
au code d'autorisation.
Une fois ce code d'autorisation récupéré, il faut désormais récupérer le jeton d'accès.
Il faut pour cela aller sur l'URL `<https://note.crans.org/o/token/>`_, effectuer une
requête POST avec pour arguments :
* ``client_id`` ;
* ``client_secret`` ;
* ``grant_type`` : mettre ``authorization_code`` ;
* ``code`` : le code généré.
À noter que le code fourni n'est disponible que pendant quelques secondes.
À des fins de tests, on peut envoyer la requête avec ``curl`` :
.. code:: bash
curl -X POST https://note.crans.org/o/token/ -d "client_id=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&client_secret=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&grant_type=authorization_code&code=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
Le serveur renverra si tout se passe bien une réponse JSON :
.. code:: json
{
"access_token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"expires_in": 36000,
"token_type": "Bearer",
"scope": "1_1 1_2",
"refresh_token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
On note donc 2 jetons différents : un d'accès et un de rafraîchissement. Le jeton d'accès
est celui qui sera donné à l'API pour s'authentifier, et qui expire au bout de quelques
heures.
Il suffit désormais d'ajouter l'en-tête ``Authorization: Bearer ACCESS_TOKEN`` pour se
connecter à la note grâce à ce jeton d'accès.
Pour tester :
.. code:: bash
curl https://note.crans.org/api/me -H "Authorization: Bearer XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
En cas d'expiration de ce jeton d'accès, il est possible de le renouveler grâce au jeton
de rafraichissement à usage unique. Il suffit pour cela de refaire une requête sur la page
`<https://note.crans.org/o/token/>`_ avec pour paramètres :
* ``client_id`` ;
* ``client_secret`` ;
* ``grant_type`` : mettre ``refresh_token`` ;
* ``refresh_token`` : le jeton de rafraîchissement.
Le serveur vous fournira alors une nouvelle paire de jetons, comme précédemment.
À noter qu'un jeton de rafraîchissement est à usage unique.
N'hésitez pas à vous renseigner sur OAuth2 pour plus d'informations.

View File

@ -88,7 +88,7 @@ On clone donc le dépôt en tant que ``www-data`` :
$ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git /var/www/note_kfet $ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git /var/www/note_kfet
Par défaut, le dépôt est configuré pour suivre la branche ``master``, qui est la branche Par défaut, le dépôt est configuré pour suivre la branche ``main``, qui est la branche
stable, notamment installée sur `<https://note.crans.org/>`_. Pour changer de branche, stable, notamment installée sur `<https://note.crans.org/>`_. Pour changer de branche,
notamment passer sur la branche ``beta`` sur un serveur de pré-production (un peu comme notamment passer sur la branche ``beta`` sur un serveur de pré-production (un peu comme
`<https://note-dev.crans.org/>`_), on peut faire : `<https://note-dev.crans.org/>`_), on peut faire :
@ -587,7 +587,7 @@ Dans ce fichier, remplissez :
--- ---
note: note:
server_name: note.crans.org server_name: note.crans.org
git_branch: master git_branch: main
cron_enabled: true cron_enabled: true
email: notekfet2020@lists.crans.org email: notekfet2020@lists.crans.org

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ from django.contrib.admin import AdminSite
from django.contrib.sites.admin import Site, SiteAdmin from django.contrib.sites.admin import Site, SiteAdmin
from member.views import CustomLoginView from member.views import CustomLoginView
from .middlewares import get_current_session
class StrongAdminSite(AdminSite): class StrongAdminSite(AdminSite):
@ -14,8 +13,7 @@ class StrongAdminSite(AdminSite):
""" """
Authorize only staff that have the correct permission mask Authorize only staff that have the correct permission mask
""" """
session = get_current_session() return request.user.is_active and request.user.is_staff and request.session.get("permission_mask", -1) >= 42
return request.user.is_active and request.user.is_staff and session.get("permission_mask", -1) >= 42
def login(self, request, extra_context=None): def login(self, request, extra_context=None):
return CustomLoginView.as_view()(request) return CustomLoginView.as_view()(request)

View File

@ -1,43 +1,23 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth import login
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.backends.db import SessionStore
from threading import local from threading import local
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') from django.conf import settings
SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session') from django.contrib.auth import login
IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') from django.contrib.auth.models import User
REQUEST_ATTR_NAME = getattr(settings, 'LOCAL_REQUEST_ATTR_NAME', '_current_request')
_thread_locals = local() _thread_locals = local()
def _set_current_user_and_ip(user=None, session=None, ip=None): def _set_current_request(request=None):
setattr(_thread_locals, USER_ATTR_NAME, user) setattr(_thread_locals, REQUEST_ATTR_NAME, request)
setattr(_thread_locals, SESSION_ATTR_NAME, session)
setattr(_thread_locals, IP_ATTR_NAME, ip)
def get_current_user() -> User: def get_current_request():
return getattr(_thread_locals, USER_ATTR_NAME, None) return getattr(_thread_locals, REQUEST_ATTR_NAME, None)
def get_current_session() -> SessionStore:
return getattr(_thread_locals, SESSION_ATTR_NAME, None)
def get_current_ip() -> str:
return getattr(_thread_locals, IP_ATTR_NAME, None)
def get_current_authenticated_user():
current_user = get_current_user()
if isinstance(current_user, AnonymousUser):
return None
return current_user
class SessionMiddleware(object): class SessionMiddleware(object):
@ -49,8 +29,6 @@ class SessionMiddleware(object):
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
user = request.user
# If we authenticate through a token to connect to the API, then we query the good user # If we authenticate through a token to connect to the API, then we query the good user
if 'HTTP_AUTHORIZATION' in request.META and request.path.startswith("/api"): if 'HTTP_AUTHORIZATION' in request.META and request.path.startswith("/api"):
token = request.META.get('HTTP_AUTHORIZATION') token = request.META.get('HTTP_AUTHORIZATION')
@ -60,20 +38,14 @@ class SessionMiddleware(object):
if Token.objects.filter(key=token).exists(): if Token.objects.filter(key=token).exists():
token_obj = Token.objects.get(key=token) token_obj = Token.objects.get(key=token)
user = token_obj.user user = token_obj.user
request.user = user
session = request.session session = request.session
session["permission_mask"] = 42 session["permission_mask"] = 42
session.save() session.save()
if 'HTTP_X_REAL_IP' in request.META: _set_current_request(request)
ip = request.META.get('HTTP_X_REAL_IP')
elif 'HTTP_X_FORWARDED_FOR' in request.META:
ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
else:
ip = request.META.get('REMOTE_ADDR')
_set_current_user_and_ip(user, request.session, ip)
response = self.get_response(request) response = self.get_response(request)
_set_current_user_and_ip(None, None, None) _set_current_request(None)
return response return response
@ -103,7 +75,7 @@ class LoginByIPMiddleware(object):
else: else:
ip = request.META.get('REMOTE_ADDR') ip = request.META.get('REMOTE_ADDR')
qs = User.objects.filter(password=f"ipbased${ip}") qs = User.objects.filter(password__iregex=f"ipbased\\$.*\\^{ip}\\$.*")
if qs.exists(): if qs.exists():
login(request, qs.get()) login(request, qs.get())
session = request.session session = request.session

View File

@ -7,6 +7,8 @@
import os import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
from datetime import timedelta
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
@ -22,6 +24,15 @@ ALLOWED_HOSTS = [
os.getenv('NOTE_URL', 'localhost'), os.getenv('NOTE_URL', 'localhost'),
] ]
# Use secure cookies in production
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
# Remember HTTPS for 1 year
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Application definition # Application definition
@ -245,6 +256,13 @@ REST_FRAMEWORK = {
'PAGE_SIZE': 20, 'PAGE_SIZE': 20,
} }
# OAuth2 Provider
OAUTH2_PROVIDER = {
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
}
# Take control on how widget templates are sourced # Take control on how widget templates are sourced
# See https://docs.djangoproject.com/en/2.2/ref/forms/renderers/#templatessetting # See https://docs.djangoproject.com/en/2.2/ref/forms/renderers/#templatessetting
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'

View File

@ -65,7 +65,7 @@ mark {
/* Last BDE colors */ /* Last BDE colors */
.bg-primary { .bg-primary {
background-color: rgb(18, 67, 4) !important; background-color: rgb(102, 83, 105) !important;
} }
html { html {
@ -81,14 +81,14 @@ body {
.btn-outline-primary:not(:disabled):not(.disabled).active, .btn-outline-primary:not(:disabled):not(.disabled).active,
.btn-outline-primary:not(:disabled):not(.disabled):active { .btn-outline-primary:not(:disabled):not(.disabled):active {
color: #fff; color: #fff;
background-color: rgb(18, 67, 46); background-color: rgb(102, 83, 105);
border-color: rgb(18, 67, 46); border-color: rgb(102, 83, 105);
} }
.btn-outline-primary { .btn-outline-primary {
color: rgb(18, 67, 46); color: rgb(102, 83, 105);
background-color: rgba(248, 249, 250, 0.9); background-color: rgba(248, 249, 250, 0.9);
border-color: rgb(18, 67, 46); border-color: rgb(102, 83, 105);
} }
.turbolinks-progress-bar { .turbolinks-progress-bar {
@ -99,35 +99,35 @@ body {
.btn-primary:not(:disabled):not(.disabled).active, .btn-primary:not(:disabled):not(.disabled).active,
.btn-primary:not(:disabled):not(.disabled):active { .btn-primary:not(:disabled):not(.disabled):active {
color: #fff; color: #fff;
background-color: rgb(18, 67, 46); background-color: rgb(102, 83, 105);
border-color: rgb(18, 67, 46); border-color: rgb(102, 83, 105);
} }
.btn-primary { .btn-primary {
color: rgba(248, 249, 250, 0.9); color: rgba(248, 249, 250, 0.9);
background-color: rgb(28, 114, 10); background-color: rgb(102, 83, 105);
border-color: rgb(18, 67, 46); border-color: rgb(102, 83, 105);
} }
.border-primary { .border-primary {
border-color: rgb(28, 114, 10) !important; border-color: rgb(115, 15, 115) !important;
} }
a { a {
color: rgb(28, 114, 10); color: rgb(102, 83, 105);
} }
a:hover { a:hover {
color: rgb(122, 163, 75); color: rgb(200, 30, 200);
} }
.form-control:focus { .form-control:focus {
box-shadow: 0 0 0 0.25rem rgba(122, 163, 75, 0.25); box-shadow: 0 0 0 0.25rem rgba(200, 30, 200, 0.25);
border-color: rgb(122, 163, 75); border-color: rgb(200, 30, 200);
} }
.btn-outline-primary.focus { .btn-outline-primary.focus {
box-shadow: 0 0 0 0.25rem rgba(122, 163, 75, 0.5); box-shadow: 0 0 0 0.25rem rgba(200, 30, 200, 0.5);
} }

View File

@ -13,21 +13,29 @@ $(document).ready(function () {
$('#' + prefix + '_reset').removeClass('d-none') $('#' + prefix + '_reset').removeClass('d-none')
$.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) { $.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) {
let html = '' let html = '<ul class="list-group list-group-flush" id="' + prefix + '_list">'
objects.results.forEach(function (obj) { objects.results.forEach(function (obj) {
html += li(prefix + '_' + obj.id, obj[name_field]) html += li(prefix + '_' + obj.id, obj[name_field])
}) })
html += '</ul>'
const results_list = $('#' + prefix + '_list') target.tooltip({
results_list.html(html) html: true,
placement: 'bottom',
trigger: 'manual',
container: target.parent(),
fallbackPlacement: 'clockwise'
})
target.attr("data-original-title", html).tooltip("show")
objects.results.forEach(function (obj) { objects.results.forEach(function (obj) {
$('#' + prefix + '_' + obj.id).click(function () { $('#' + prefix + '_' + obj.id).click(function () {
target.val(obj[name_field]) target.val(obj[name_field])
$('#' + prefix + '_pk').val(obj.id) $('#' + prefix + '_pk').val(obj.id)
results_list.html('') target.tooltip("hide")
target.removeClass('is-invalid') target.removeClass('is-invalid')
target.addClass('is-valid') target.addClass('is-valid')
@ -37,8 +45,8 @@ $(document).ready(function () {
if (input === obj[name_field]) { $('#' + prefix + '_pk').val(obj.id) } if (input === obj[name_field]) { $('#' + prefix + '_pk').val(obj.id) }
}) })
if (results_list.children().length === 1 && e.originalEvent.keyCode >= 32) { if (objects.results.length === 1 && e.originalEvent.keyCode >= 32) {
results_list.children().first().trigger('click') $('#' + prefix + '_' + objects.results[0].id).trigger('click')
} }
}) })
}) })

View File

@ -7,8 +7,8 @@
* @returns {string} * @returns {string}
*/ */
function pretty_money (value) { function pretty_money (value) {
if (value % 100 === 0) { return (value < 0 ? '- ' : '') + Math.round(Math.abs(value) / 100) + ' €' } else { if (value % 100 === 0) { return (value < 0 ? '- ' : '') + Math.floor(Math.abs(value) / 100) + ' €' } else {
return (value < 0 ? '- ' : '') + Math.round(Math.abs(value) / 100) + '.' + return (value < 0 ? '- ' : '') + Math.floor(Math.abs(value) / 100) + '.' +
(Math.abs(value) % 100 < 10 ? '0' : '') + (Math.abs(value) % 100) + ' €' (Math.abs(value) % 100 < 10 ? '0' : '') + (Math.abs(value) % 100) + ' €'
} }
} }
@ -96,7 +96,11 @@ function displayStyle (note) {
if (!note) { return '' } if (!note) { return '' }
const balance = note.balance const balance = note.balance
var css = '' var css = ''
if (balance < -5000) { css += ' text-danger bg-dark' } else if (balance < -1000) { css += ' text-danger' } else if (balance < 0) { css += ' text-warning' } else if (!note.email_confirmed) { css += ' text-white bg-primary' } else if (!note.is_active || (note.membership && note.membership.date_end < new Date().toISOString())) { css += 'text-white bg-info' } if (balance < -5000) { css += ' text-danger bg-dark' }
else if (balance < -1000) { css += ' text-danger' }
else if (balance < 0) { css += ' text-warning' }
if (!note.email_confirmed) { css += ' bg-primary' }
else if (!note.is_active || (note.membership && note.membership.date_end < new Date().toISOString())) { css += ' bg-info' }
return css return css
} }
@ -377,11 +381,11 @@ function de_validate (id, validated, resourcetype) {
* @param callback Function to call * @param callback Function to call
* @param wait Debounced milliseconds * @param wait Debounced milliseconds
*/ */
function debounce (callback, wait) { let debounce_timeout
let timeout function debounce (callback, wait=500) {
return (...args) => { return (...args) => {
const context = this const context = this
clearTimeout(timeout) clearTimeout(debounce_timeout)
timeout = setTimeout(() => callback.apply(context, args), wait) debounce_timeout = setTimeout(() => callback.apply(context, args), wait)
} }
} }

File diff suppressed because one or more lines are too long

View File

@ -9,9 +9,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
name="{{ widget.name }}_name" autocomplete="off" name="{{ widget.name }}_name" autocomplete="off"
{% for name, value in widget.attrs.items %} {% for name, value in widget.attrs.items %}
{% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %} {% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
{% endfor %}> {% endfor %}
aria-describedby="{{widget.attrs.id}}_tooltip">
{% if widget.resetable %} {% if widget.resetable %}
<a id="{{ widget.attrs.id }}_reset" class="btn btn-light autocomplete-reset{% if not widget.value %} d-none{% endif %}">{% trans "Reset" %}</a> <a id="{{ widget.attrs.id }}_reset" class="btn btn-light autocomplete-reset{% if not widget.value %} d-none{% endif %}">{% trans "Reset" %}</a>
{% endif %} {% endif %}
<ul class="list-group list-group-flush" id="{{ widget.attrs.id }}_list">
</ul>

View File

@ -33,8 +33,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static "jquery/jquery.min.js" %}"></script> <script src="{% static "jquery/jquery.min.js" %}"></script>
<script src="{% static "popper.js/umd/popper.min.js" %}"></script> <script src="{% static "popper.js/umd/popper.min.js" %}"></script>
<script src="{% static "bootstrap4/js/bootstrap.min.js" %}"></script> <script src="{% static "bootstrap4/js/bootstrap.min.js" %}"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js" <script src="{% static "js/turbolinks.js" %}"></script>
crossorigin="anonymous"></script>
<script src="{% static "js/base.js" %}"></script> <script src="{% static "js/base.js" %}"></script>
<script src="{% static "js/konami.js" %}"></script> <script src="{% static "js/konami.js" %}"></script>
@ -159,10 +158,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="alert alert-danger"> <div class="alert alert-danger">
{% trans "You are not a BDE member anymore. Please renew your membership if you want to use the note." %} {% trans "You are not a BDE member anymore. Please renew your membership if you want to use the note." %}
</div> </div>
{% elif not user|is_member:"Kfet" %}
<div class="alert alert-warning">
{% trans "You are not a Kfet member, so you can't use your note account." %}
</div>
{% endif %} {% endif %}
{% if not user.profile.email_confirmed %} {% if not user.profile.email_confirmed %}
@ -174,8 +169,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if user.sogecredit and not user.sogecredit.valid %} {% if user.sogecredit and not user.sogecredit.valid %}
<div class="alert alert-info"> <div class="alert alert-info">
{% blocktrans trimmed %} {% blocktrans trimmed %}
You declared that you opened a bank account in the Société générale. The bank did not validate the creation of the account to the BDE, You declared that you opened a bank account in the Société générale. The bank did not validate
so the registration bonus of 80 € is not credited and the membership is not paid yet. the creation of the account to the BDE, so the membership and the WEI are not paid yet.
This verification procedure may last a few days. This verification procedure may last a few days.
Please make sure that you go to the end of the account creation. Please make sure that you go to the end of the account creation.
{% endblocktrans %} {% endblocktrans %}
@ -197,6 +192,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<span class="text-muted mr-1"> <span class="text-muted mr-1">
<a href="mailto:{{ "CONTACT_EMAIL" | getenv }}" <a href="mailto:{{ "CONTACT_EMAIL" | getenv }}"
class="text-muted">{% trans "Contact us" %}</a> &mdash; class="text-muted">{% trans "Contact us" %}</a> &mdash;
<a href="mailto:{{ "SUPPORT_EMAIL" | getenv }}"
class="text-muted">{% trans "Technical Support" %}</a> &mdash;
</span> </span>
{% csrf_token %} {% csrf_token %}
<select title="language" name="language" <select title="language" name="language"

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Are you sure to delete the application" %} {{ application.name }}?</h3>
</div>
<div class="card-footer text-center">
<form method="post" action="{% url 'oauth2_provider:delete' application.pk %}">
{% csrf_token %}
<div class="control-group">
<div class="controls">
<a class="btn btn-secondary btn-large" href="{% url "oauth2_provider:list" %}">{% trans "Cancel" %}</a>
<input type="submit" class="btn btn-large btn-danger" name="allow" value="{% trans "Delete" %}"/>
</div>
</div>
</form>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3>{{ application.name }}</h3>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-xl-6">{% trans "Client id" %}</dt>
<dd class="col-xl-6"><input class="form-control" type="text" value="{{ application.client_id }}" readonly></dd>
<dt class="col-xl-6">{% trans "Client secret" %}</dt>
<dd class="col-xl-6"><input class="form-control" type="text" value="****************************************************************" readonly></dd>
<dt class="col-xl-6">{% trans "Client type" %}</dt>
<dd class="col-xl-6">{{ application.client_type }}</dd>
<dt class="col-xl-6">{% trans "Authorization Grant Type" %}</dt>
<dd class="col-xl-6">{{ application.authorization_grant_type }}</dd>
<dt class="col-xl-6">{% trans "Redirect Uris" %}</dt>
<dd class="col-xl-6"><textarea class="form-control" readonly>{{ application.redirect_uris }}</textarea></dd>
</dl>
<div class="alert alert-info">
{% url 'permission:scopes' as scopes_url %}
{% blocktrans trimmed %}
You can go <a href="{{ scopes_url }}">here</a> to generate authorization link templates and convert
permissions to scope numbers with the permissions that you want to grant for your application.
{% endblocktrans %}
</div>
</div>
<div class="card-footer text-center">
<a class="btn btn-secondary" href="{% url "oauth2_provider:list" %}">{% trans "Go Back" %}</a>
<a class="btn btn-primary" href="{% url "oauth2_provider:update" application.id %}">{% trans "Edit" %}</a>
<a class="btn btn-danger" href="{% url "oauth2_provider:delete" application.id %}">{% trans "Delete" %}</a>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<form class="form-horizontal" method="post" action="{% block app-form-action-url %}{% url 'oauth2_provider:update' application.id %}{% endblock app-form-action-url %}">
<div class="card">
<div class="card-header text-center">
<h3 class="block-center-heading">
{% block app-form-title %}
{% trans "Edit application" %} {{ application.name }}
{% endblock app-form-title %}
</h3>
</div>
<div class="card-body">
{% csrf_token %}
{{ form|crispy }}
</div>
<div class="card-footer text-center control-group">
<div class="controls">
<a class="btn btn-secondary" href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.id %}{% endblock app-form-back-url %}">
{% trans "Go Back" %}
</a>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</div>
</form>
{% endblock %}

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