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

Compare commits

...

188 Commits

Author SHA1 Message Date
5c77b6ebfa Fix tests
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2022-08-29 14:52:34 +02:00
92610c5ffd C'est pas moi
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2022-08-29 13:22:01 +02:00
ddbdd5b5d0 De l'inclusif, partout
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2022-08-29 13:20:17 +02:00
e3ee39ca81 [scripts] Remove README and add link to documentation
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2022-08-29 11:54:30 +02:00
0c1c845f72 Fresh installations are made with Debian Bullseye
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2022-08-29 11:41:48 +02:00
edcf42beb6 Update Documentation Copyright
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2022-08-29 11:24:27 +02:00
3306aed6dc Replace ... by …
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2022-08-29 11:22:44 +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
144 changed files with 6236 additions and 2396 deletions

1
.gitignore vendored
View File

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

View File

@ -1,8 +1,8 @@
# NoteKfet 2020
[![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)
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.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/main/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
## Table des matières
@ -55,7 +55,7 @@ Bien que cela permette de créer une instance sur toutes les distributions,
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
(env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
(env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
```
6. Enjoy :

View File

@ -7,7 +7,7 @@
prompt: "Password of the database (leave it blank to skip database init)"
private: yes
vars:
mirror: mirror.crans.org
mirror: eclats.crans.org
roles:
- 1-apt-basic
- 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:
server_name: note-dev.crans.org
git_branch: beta
serve_static: false
cron_enabled: false
email: notekfet2020@lists.crans.org

View File

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

View File

@ -1,6 +1,5 @@
[dev]
bde-note-dev.adh.crans.org
bde-nk20-beta.adh.crans.org
[prod]
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:
repo: deb http://{{ mirror }}/debian buster-backports main
state: present
when: ansible_facts['distribution'] == "Debian"
when:
- ansible_distribution == "Debian"
- ansible_distribution_major_version | int == 10
- name: Install note_kfet APT dependencies
apt:
update_cache: true
default_release: "{{ 'buster-backports' if ansible_facts['distribution'] == 'Debian' }}"
install_recommends: false
name:
# Common tools

View File

@ -41,6 +41,7 @@ server {
# max upload size
client_max_body_size 75M; # adjust to taste
{% if note.serve_static %}
# Django media
location /media {
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
}
{% endif %}
location /doc {
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 note.models import Note, NoteUser
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 .models import Activity, Guest
@ -24,9 +24,15 @@ class ActivityForm(forms.ModelForm):
self.fields["attendees_club"].initial = Club.objects.get(name="Kfet")
self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet"
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)
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):
date_end = self.cleaned_data["date_end"]
@ -47,7 +53,7 @@ class ActivityForm(forms.ModelForm):
model=Note,
attrs={
"api_url": "/api/note/note/",
'placeholder': 'Note de l\'événement sur laquelle envoyer les crédits d\'invitation ...'
'placeholder': 'Note de l\'événement sur laquelle envoyer les crédits d\'invitation'
},
),
"attendees_club": Autocomplete(
@ -109,7 +115,7 @@ class GuestForm(forms.ModelForm):
# We don't evaluate the content type at launch because the DB might be not initialized
'api_url_suffix':
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteUser).pk),
'placeholder': 'Note ...',
'placeholder': 'Note',
},
),
}

View File

@ -126,7 +126,7 @@ class Activity(models.Model):
@transaction.atomic
def save(self, *args, **kwargs):
"""
Update the activity wiki page each time the activity is updated (validation, change description, ...)
Update the activity wiki page each time the activity is updated (validation, change description,)
"""
if self.date_end < self.date_start:
raise ValidationError(_("The end date must be after the start date."))

View File

@ -1,7 +1,9 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
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 _
import django_tables2 as tables
from django_tables2 import A
@ -52,7 +54,7 @@ class GuestTable(tables.Table):
def render_entry(self, record):
if record.has_entry:
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()))
@ -91,7 +93,7 @@ class EntryTable(tables.Table):
if hasattr(record, 'username'):
username = record.username
if username != value:
return format_html(value + " <em>aka.</em> " + username)
return mark_safe(escape(value) + " <em>aka.</em> " + escape(username))
return value
def render_balance(self, value):

View File

@ -37,7 +37,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<button class="btn btn-light">{% trans "Return to activity page" %}</button>
</a>
<input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
<input id="alias" type="text" class="form-control" placeholder="Nom/note">
<hr>
@ -63,7 +63,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
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);

View File

@ -66,21 +66,19 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
ordering = ('-date_start',)
extra_context = {"title": _("Activities")}
def get_queryset(self):
return super().get_queryset().distinct()
def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
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-',
)
started_activities = Activity.objects\
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.filter(open=True, valid=True).all()
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
context["started_activities"] = started_activities
return context
@ -98,7 +96,7 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data()
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["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):
form = super().get_form(form_class)
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.get(pk=self.kwargs["pk"])
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.filter(pk=self.kwargs["pk"]).first()
form.fields["inviter"].initial = self.request.user.note
return form
@transaction.atomic
def form_valid(self, form):
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)
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),
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"])
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."))
if not activity.activity_type.manage_entries:
@ -191,8 +192,8 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
guest_qs = Guest.objects\
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
.filter(activity=activity)\
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
.order_by('last_name', 'first_name').distinct()
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
.order_by('last_name', 'first_name')
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
@ -206,7 +207,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
)
else:
guest_qs = guest_qs.none()
return guest_qs
return guest_qs.distinct()
def get_invited_note(self, activity):
"""
@ -230,7 +231,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
)
# 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"]:
pattern = self.request.GET["search"]
@ -256,7 +257,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
"""
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"])
context["activity"] = activity
@ -281,9 +282,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
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
if PermissionBackend.check_perm(self.request.user,
if PermissionBackend.check_perm(self.request,
"activity.add_entry",
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 rest_framework import serializers
from member.api.serializers import ProfileSerializer, MembershipSerializer
from member.models import Membership
from note.api.serializers import NoteSerializer
from note.models import Alias
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
class UserSerializer(serializers.ModelSerializer):
@ -45,18 +48,30 @@ class OAuthSerializer(serializers.ModelSerializer):
"""
normalized_name = serializers.SerializerMethodField()
profile = ProfileSerializer()
profile = serializers.SerializerMethodField()
note = NoteSerializer()
note = serializers.SerializerMethodField()
memberships = serializers.SerializerMethodField()
def get_normalized_name(self, obj):
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):
# Display only memberships that we are allowed to see.
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:
model = User

View File

@ -9,7 +9,6 @@ from django.contrib.auth.models import User
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
from permission.backends import PermissionBackend
from note_kfet.middlewares import get_current_session
from note.models import Alias
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()
def get_queryset(self):
user = self.request.user
get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
@ -40,9 +37,7 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self):
user = self.request.user
get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
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.serializers import ModelSerializer
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
@ -56,13 +56,13 @@ def save_object(sender, instance, **kwargs):
# noinspection PyProtectedMember
previous = instance._previous
# 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()
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse 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`
# 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⋅rice dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
@ -71,9 +71,23 @@ def save_object(sender, instance, **kwargs):
# else:
if note.exists():
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
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
if instance.last_login != previous.last_login:
return
@ -120,13 +134,13 @@ def delete_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
# 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()
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse 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`
# 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⋅rice dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
@ -135,6 +149,20 @@ def delete_object(sender, instance, **kwargs):
# else:
if note.exists():
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
class CustomSerializer(ModelSerializer):

View File

@ -200,9 +200,9 @@ class MembershipForm(forms.ModelForm):
class Meta:
model = Membership
fields = ('user', 'date_start')
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
# Le champ d'utilisateur⋅rice est remplacé par un champ d'auto-complétion.
# Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion
# et récupère les noms d'utilisateur valides
# et récupère les noms d'utilisateur⋅rices valides
widgets = {
'user':
Autocomplete(
@ -210,7 +210,7 @@ class MembershipForm(forms.ModelForm):
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ...',
'placeholder': 'Nom',
},
),
'date_start': DatePickerInput(),
@ -227,7 +227,7 @@ class MembershipRolesForm(forms.ModelForm):
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ...',
'placeholder': 'Nom',
},
),
)

View File

@ -2,11 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import hashlib
from collections import OrderedDict
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 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):
@ -24,16 +26,22 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
def must_update(self, encoded):
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:
return False
return True
def verify(self, password, encoded):
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\
and get_current_session().get("permission_mask", -1) >= 42:
and request.session.get("permission_mask", -1) >= 42:
return True
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 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):
"""
@ -51,8 +71,11 @@ class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
def verify(self, password, encoded):
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\
and get_current_session().get("permission_mask", -1) >= 42:
and request.session.get("permission_mask", -1) >= 42:
return True
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_unpaid=500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
membership_start="2021-08-01",
membership_end="2022-09-30",
)
Club.objects.get_or_create(
id=2,
@ -31,8 +31,8 @@ def create_bde_and_kfet(apps, schema_editor):
membership_fee_paid=3500,
membership_fee_unpaid=3500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
membership_start="2021-08-01",
membership_end="2022-09-30",
)
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)")),
('A2', _("Physics (A2)")),
("A'2", _("Applied physics (A'2)")),
('A''2', _("Chemistry (A''2)")),
("A''2", _("Chemistry (A''2)")),
('A3', _("Biology (A3)")),
('B1234', _("SAPHIRE (B1234)")),
('B1', _("Mechanics (B1)")),
@ -74,7 +74,7 @@ class Profile(models.Model):
promotion = models.PositiveSmallIntegerField(
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"),
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.
Update the year of the membership dates.
"""
if not self.membership_start:
if not self.membership_start or not self.membership_end:
return
today = datetime.date.today()
if (today - self.membership_start).days >= 365:
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1,
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.month, self.membership_end.day)
self._force_save = True
@ -400,10 +402,10 @@ class Membership(models.Model):
if self.club.parent_club.name == "BDE":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all())
elif self.club.parent_club.name == "Kfet":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
else:
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
parent_membership.save()
@ -413,6 +415,12 @@ class Membership(models.Model):
"""
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
if not created:
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.utils.html import format_html
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 .models import Club, Membership
@ -31,7 +31,8 @@ class ClubTable(tables.Table):
row_attrs = {
'class': 'table-row',
'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):
# Replace the email by a dash if the user can't see the profile detail
# 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 = ""
record.email = value
return value
def render_section(self, record, 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 ""
def render_balance(self, record, 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:
attrs = {
@ -74,7 +75,8 @@ class UserTable(tables.Table):
model = User
row_attrs = {
'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):
# If the user has the right, link the displayed user with the page of its detail.
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>",
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):
# If the user has the right, link the displayed club with the page of its detail.
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>",
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
@ -118,7 +120,7 @@ class MembershipTable(tables.Table):
club=record.club,
user=record.user,
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
empty_membership = Membership(
club=record.club,
@ -127,7 +129,7 @@ class MembershipTable(tables.Table):
date_end=date.today(),
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
renew_url = reverse_lazy('member:club_renew_membership',
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
roles = record.roles.all()
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 + "</a>")
return s
@ -165,7 +167,7 @@ class ClubManagerTable(tables.Table):
def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail.
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>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)

View File

@ -25,6 +25,14 @@
</a>
</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 %}
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.section }}</dd>
@ -39,13 +47,13 @@
<dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
<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>
<dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd>
{% 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 %}
</dl>

View File

@ -5,17 +5,26 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% 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>
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>
pour pouvoir vous identifier.<br /><br />
Une documentation de l'API arrivera ultérieurement.
</div>
La documentation de l'API est disponible ici :
<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>
{% if 'show' in request.GET %}
{{ token.key }} (<a href="?">cacher</a>)
@ -24,13 +33,70 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
<br />
<strong>{%trans 'Created' %} :</strong> {{ token.created }}
</div>
</div>
<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 !
</div>
<a href="?regenerate">
<div class="alert alert-warning">
<strong>{% trans "Warning" %} :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
</div>
</div>
<div class="card-footer text-center">
<a href="?regenerate">
<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 %}

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

@ -291,7 +291,7 @@ class TestMemberships(TestCase):
response = self.client.post(reverse("member:club_manage_roles", args=(self.membership.pk,)), data=dict(
roles=[role.id for role in Role.objects.filter(
Q(name="Membre de club") | Q(name="Trésorier·ère de club") | Q(name="Bureau de club")).all()],
Q(name="Membre de club") | Q(name="Trésorière de club") | Q(name="Bureau de club")).all()],
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.membership.refresh_from_db()

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_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>/trust', views.ProfileTrustView.as_view(), name="user_trust"),
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.models import User
from django.contrib.auth.views import LoginView
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Q, F
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_tables2.views import SingleTableView
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.tables import HistoryTable, AliasTable
from note_kfet.middlewares import _set_current_user_and_ip
from note.tables import HistoryTable, AliasTable, TrustTable
from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend
from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
@ -41,7 +42,8 @@ class CustomLoginView(LoginView):
@transaction.atomic
def form_valid(self, form):
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
return super().form_valid(form)
@ -70,7 +72,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.fields['email'].required = True
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,
data=self.request.POST if self.request.POST else None)
if not self.object.profile.report_frequency:
@ -153,13 +155,13 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\
.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.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1))
context['history_list'] = history_table
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")
# Display only the most recent membership
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)
# Don't log these tests
modified_note._no_signal = True
modified_note.is_active = True
modified_note.is_active = False
modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_noteuser_is_active",
modified_note)
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk)
modified_note.inactivity_reason = 'forced'
modified_note._force_save = True
modified_note.save()
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._no_signal = True
old_note.save()
modified_note.refresh_from_db()
modified_note.is_active = True
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
@ -237,12 +238,45 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
def get_context_data(self, **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)
context["can_manage_registrations"] = pre_registered_users.exists()
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):
"""
View and manage user aliases.
@ -256,8 +290,9 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["aliases"] = AliasTable(
note.alias.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
.order_by('normalized_name').all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note,
name="",
normalized_name="",
@ -382,7 +417,7 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
def get_context_data(self, **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="",
email="club@example.com",
))
@ -403,9 +438,12 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
context = super().get_context_data(**kwargs)
club = context["club"]
if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club):
club = self.object
context["note"] = club.note
if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
club.update_membership_dates()
# managers list
managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club",
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-")
# transaction history
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')
history_table = HistoryTable(club_transactions, prefix="history-")
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=club,
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")
# Display only the most recent membership
club_member = club_member.distinct("user__username")\
@ -443,6 +481,29 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_add_members"] = PermissionBackend()\
.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
@ -459,8 +520,8 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["aliases"] = AliasTable(note.alias.filter(
PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note,
name="",
normalized_name="",
@ -535,7 +596,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
form = context['form']
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)
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.
@ -655,8 +716,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
if club.name != "Kfet" and club.parent_club and not Membership.objects.filter(
user=form.instance.user,
club=club.parent_club,
date_start__lte=timezone.now(),
date_end__gte=club.parent_club.membership_end,
date_start__gte=club.parent_club.membership_start,
).exists():
form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name)
error = True
@ -684,7 +744,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
"""
# Get the club that is concerned by the 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"])
user = form.instance.user
old_membership = None
@ -760,8 +820,8 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
ret = super().form_valid(form)
member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \
if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \
member_role = Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all() \
if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all() \
if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all()
# Set the same roles as before
if old_membership:
@ -797,7 +857,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
membership.refresh_from_db()
if old_membership.exists():
membership.roles.set(old_membership.get().roles.all())
membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
membership.roles.set(Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
membership.save()
return ret
@ -868,7 +928,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = Club.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Club, "view")
PermissionBackend.filter_queryset(self.request, Club, "view")
).get(pk=self.kwargs["pk"])
context["club"] = club

View File

@ -8,11 +8,11 @@ from rest_framework.exceptions import ValidationError
from rest_polymorphic.serializers import PolymorphicSerializer
from member.api.serializers import MembershipSerializer
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 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, \
RecurrentTransaction, SpecialTransaction
@ -77,6 +77,22 @@ class NoteUserSerializer(serializers.ModelSerializer):
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):
"""
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
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(
id=obj.note.id,
name=str(obj.note),
@ -142,7 +158,7 @@ class ConsumerSerializer(serializers.ModelSerializer):
def get_membership(self, obj):
if isinstance(obj.note, NoteUser):
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,
club=2, # Kfet
).order_by("-date_start")

View File

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

View File

@ -1,5 +1,6 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from django.conf import settings
from django.db.models import Q
@ -10,12 +11,12 @@ from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework import status
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from note_kfet.middlewares import get_current_session
from permission.backends import PermissionBackend
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer, \
TrustSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial, Trust
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
@ -40,12 +41,11 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
Parse query and apply filters.
:return: The filtered set of requested notes
"""
user = self.request.user
get_current_session().setdefault("permission_mask", 42)
queryset = self.queryset.filter(PermissionBackend.filter_queryset(user, Note, "view")
| PermissionBackend.filter_queryset(user, NoteUser, "view")
| PermissionBackend.filter_queryset(user, NoteClub, "view")
| PermissionBackend.filter_queryset(user, NoteSpecial, "view")).distinct()
queryset = self.queryset.filter(PermissionBackend.filter_queryset(self.request, Note, "view")
| PermissionBackend.filter_queryset(self.request, NoteUser, "view")
| PermissionBackend.filter_queryset(self.request, NoteClub, "view")
| PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\
.distinct()
alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter(
@ -57,17 +57,48 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
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):
"""
REST API View set.
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
serializer_class = AliasSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
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', ]
def get_serializer_class(self):
@ -118,7 +149,8 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
serializer_class = ConsumerSerializer
filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
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', ]
def get_queryset(self):
@ -133,23 +165,31 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
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')
if alias:
# We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias.
queryset = queryset.filter(
name__iregex="^" + alias
**{f'name{suffix}': alias_prefix + alias}
).union(
queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(**{f'name{suffix}': alias_prefix + alias})
),
all=True).union(
queryset.filter(
Q(normalized_name__iregex="^" + alias.lower())
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
& ~Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(**{f'name{suffix}': alias_prefix + alias})
),
all=True)
@ -205,7 +245,5 @@ class TransactionViewSet(ReadProtectedModelViewSet):
ordering_fields = ['created_at', 'amount', ]
def get_queryset(self):
user = self.request.user
get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\
return self.model.objects.filter(PermissionBackend.filter_queryset(self.request, self.model, "view"))\
.order_by("created_at", "id")

View File

@ -26,7 +26,7 @@ class TransactionTemplateForm(forms.ModelForm):
# We don't evaluate the content type at launch because the DB might be not initialized
'api_url_suffix':
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteClub).pk),
'placeholder': 'Note ...',
'placeholder': 'Note',
},
),
'amount': AmountInput(),
@ -43,7 +43,7 @@ class SearchTransactionForm(forms.Form):
resetable=True,
attrs={
'api_url': '/api/note/alias/',
'placeholder': 'Note ...',
'placeholder': 'Note',
},
),
)
@ -57,7 +57,7 @@ class SearchTransactionForm(forms.Form):
resetable=True,
attrs={
'api_url': '/api/note/alias/',
'placeholder': 'Note ...',
'placeholder': 'Note',
},
),
)

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': 'friendship',
'verbose_name_plural': 'friendships',
'unique_together': {('trusting', 'trusted')},
},
),
]

View File

@ -1,13 +1,13 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# 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, \
TemplateCategory, TransactionTemplate, RecurrentTransaction, SpecialTransaction
__all__ = [
# Notes
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
'Alias', 'Trust', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
# Transactions
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
'RecurrentTransaction', 'SpecialTransaction',

View File

@ -217,6 +217,38 @@ class NoteSpecial(Note):
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 = _("friendship")
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):
"""
points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance.

View File

@ -20,7 +20,7 @@ class TemplateCategory(models.Model):
"""
Defined a recurrent transaction category
Example: food, softs, ...
Example: food, softs,
"""
name = models.CharField(
verbose_name=_("name"),
@ -40,7 +40,7 @@ class TransactionTemplate(models.Model):
"""
Defined a recurrent transaction
associated to selling something (a burger, a beer, ...)
associated to selling something (a burger, a beer,)
"""
name = models.CharField(
verbose_name=_('name'),

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 () {
if (LOCK) { return }
@ -348,14 +355,14 @@ $('#btn_transfer').click(function () {
destination_alias: dest.name
}).done(function () {
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()
}).fail(function (err) {
const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText }
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
})
})

View File

@ -4,13 +4,13 @@
import html
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.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 .models.notes import Alias
from .models.notes import Alias, Trust
from .models.transactions import Transaction, TransactionTemplate
from .templatetags.pretty_money import pretty_money
@ -88,16 +88,16 @@ class HistoryTable(tables.Table):
"class": lambda record:
str(record.valid).lower()
+ (' 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 ''),
"data-toggle": "tooltip",
"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)
and record.source.is_active and record.destination.is_active else None,
"onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower()
+ ', "' + 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)
and record.source.is_active and record.destination.is_active else None,
"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
"""
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 ""
@ -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 Meta:
attrs = {
@ -165,7 +190,7 @@ class AliasTable(tables.Table):
extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': lambda record: 'col-sm-1' + (
' 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"), )
@ -197,6 +222,17 @@ class ButtonTable(tables.Table):
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,
extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}},
@ -204,3 +240,16 @@ class ButtonTable(tables.Table):
def render_amount(self, 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

@ -40,7 +40,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{# User search with autocompletion #}
<div class="card-footer">
<input class="form-control mx-auto d-block"
placeholder="{% trans "Name or alias..." %}" type="text" id="note" autofocus />
placeholder="{% trans "Name or alias" %}" type="text" id="note" autofocus />
</div>
</div>
</div>

View File

@ -22,7 +22,7 @@
</p>
<p>
Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
Par ailleurs, le BDE ne sert pas d'alcool aux adhérent⋅es dont le solde
est inférieur à 0 € depuis plus de 24h.
</p>

View File

@ -9,7 +9,7 @@ Ce mail t'a été envoyé parce que le solde de ta Note Kfet
Ton solde actuel est de {{ note.balance|pretty_money }}.
Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
Par ailleurs, le BDE ne sert pas d'alcool aux adhérent⋅es dont le solde
est inférieur à 0 € depuis plus de 24h.
Si tu ne comprends pas ton solde, tu peux consulter ton historique

View File

@ -10,6 +10,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
{# bandeau transfert/crédit/débit/activité #}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-block">
<div class="btn-group btn-group-toggle btn-block" data-toggle="buttons">
<label for="type_transfer" class="btn btn-sm btn-outline-primary active">
<input type="radio" name="transaction_type" id="type_transfer">
@ -25,6 +26,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
{% trans "Debit" %}
</label>
{% endif %}
</div>
{# Add shortcuts for opened activites if necessary #}
{% for activity in activities_open %}
<a href="{% url "activity:activity_entry" pk=activity.pk %}" class="btn btn-sm btn-outline-primary">
{% trans "Entries" %} {{ activity.name }}
@ -62,7 +66,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %}
</select>
<input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias..." %}" />
<input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias" %}" />
<div id="source_me_div">
<hr>
<a class="btn-block btn btn-secondary" href="#" id="source_me" data-turbolinks="false">
@ -89,14 +93,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %}
</select>
<input class="form-control mx-auto" type="text" id="dest_note" placeholder="{% trans "Name or alias..." %}" />
<input class="form-control mx-auto" type="text" id="dest_note" placeholder="{% trans "Name or alias" %}" />
<ul class="list-group list-group-flush" id="dest_alias_matched">
</ul>
</div>
</div>
</div>
{# Information on transaction (amount, reason, name,...) #}
{# Information on transaction (amount, reason, name,) #}
<div class="col-md" id="external_div">
<div class="card bg-light mb-4">
<div class="card-header">

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="row justify-content-center mb-4">
<div class="col-md-10 text-center">
{# Search field , see js #}
<input class="form-control mx-auto w-25" type="text" id="search_field" placeholder="{% trans "Name of the button..." %}" value="{{ request.GET.search }}">
<input class="form-control mx-auto w-25" type="text" id="search_field" placeholder="{% trans "Name of the button" %}" value="{{ request.GET.search }}">
<hr>
<a class="btn btn-primary text-center my-1" href="{% url 'note:template_create' %}" data-turbolinks="false">{% trans "New button" %}</a>
</div>
@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="col-md-12">
<div class="card card-border shadow">
<div class="card-header text-center">
<h5> {% trans "buttons listing "%}</h5>
<h5>{% trans "buttons listing"%}</h5>
</div>
<div class="card-body px-0 py-0" id="buttons_table">
{% render_table table %}
@ -31,14 +31,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block extrajavascript %}
<script type="text/javascript">
$(document).ready(function() {
let searchbar_obj = $("#search_field");
let timer_on = false;
let timer;
function refreshMatchedWords() {
$("tr").each(function() {
let pattern = searchbar_obj.val();
let pattern = $('#search_field').val();
if (pattern) {
$(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>"));
@ -47,13 +42,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
});
}
refreshMatchedWords();
function reloadTable() {
let pattern = searchbar_obj.val();
let pattern = $('#search_field').val();
$("#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() {
if (timer_on)
clearTimeout(timer);
@ -70,12 +70,35 @@ SPDX-License-Identifier: GPL-3.0-or-later
headers: {"X-CSRFTOKEN": CSRF_TOKEN}
})
.done(function() {
addMsg('{% trans "button successfully deleted "%}','success');
addMsg('{% trans "button successfully deleted"%}','success');
$("#buttons_table").load(location.pathname + "?search=" + $("#search_field").val().replace(" ", "%20") + " #buttons_table");
})
.fail(function() {
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>
{% endblock %}

View File

@ -38,7 +38,7 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
def get_queryset(self, **kwargs):
# retrieves only Transaction that user has the right to see.
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]
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['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
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()
# Add a shortcut for entry page for open activities
if "activity" in settings.INSTALLED_APPS:
from activity.models import Activity
activities_open = Activity.objects.filter(open=True).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all()
activities_open = Activity.objects.filter(open=True, activity_type__manage_entries=True).filter(
PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
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",
Entry(activity=a,
note=self.request.user.note, ))]
@ -90,9 +90,9 @@ class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, Sing
if "search" in self.request.GET:
pattern = self.request.GET["search"]
qs = qs.filter(
Q(name__iregex="^" + pattern)
| Q(destination__club__name__iregex="^" + pattern)
| Q(category__name__iregex="^" + pattern)
Q(name__iregex=pattern)
| Q(destination__club__name__iregex=pattern)
| Q(category__name__iregex=pattern)
| Q(description__iregex=pattern)
)
@ -159,7 +159,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
return self.handle_no_permission()
templates = TransactionTemplate.objects.filter(
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
)
if not templates.exists():
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.
"""
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]
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 category in categories:
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()
context['categories'] = [cat for cat in categories if cat.templates_filtered]
# some transactiontemplate are put forward to find them easily
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()
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 {}
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')
if "source" in data and data["source"]:

View File

@ -4,12 +4,12 @@
from datetime import date
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.db.models import Q, F
from django.utils import timezone
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 .decorators import memoize
@ -26,14 +26,31 @@ class PermissionBackend(ModelBackend):
@staticmethod
@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.
:param user: The owner of the permissions
:param request: The current request
: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
"""
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
return Permission.objects.none()
@ -43,7 +60,7 @@ class PermissionBackend(ModelBackend):
for membership in memberships:
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 membership.date_start > date.today() or membership.date_end < date.today():
continue
@ -52,16 +69,22 @@ class PermissionBackend(ModelBackend):
return perms
@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
:param user: The owner of the permissions
:param request: The current request
:param model: The model that the permissions shoud apply
:param type: The type of the permissions: view, change, add or delete
: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:
continue
@ -88,20 +111,26 @@ class PermissionBackend(ModelBackend):
@staticmethod
@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.
:param user: The owner of the permissions that are fetched
:param request: The current request
:param model: The concerned model of the queryset
:param t: The type of modification (view, add, change, delete)
:param field: The field of the model to test, if concerned
: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
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
return Q()
@ -110,7 +139,7 @@ class PermissionBackend(ModelBackend):
# Never satisfied
query = Q(pk=-1)
perms = PermissionBackend.permissions(user, model, t)
perms = PermissionBackend.permissions(request, model, t)
for perm in perms:
if perm.field and field != perm.field:
continue
@ -122,7 +151,7 @@ class PermissionBackend(ModelBackend):
@staticmethod
@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.
The result is then memoized.
@ -130,10 +159,19 @@ class PermissionBackend(ModelBackend):
primary key, the result is not memoized. Moreover, the right 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
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:
return True
@ -147,16 +185,19 @@ class PermissionBackend(ModelBackend):
ct = ContentType.objects.get_for_model(obj)
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 False
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):
return False
def get_all_permissions(self, user_obj, obj=None):
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 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):
@ -48,11 +48,11 @@ def memoize(f):
last_collect = time()
# If there is no session, then we don't memoize anything.
sess = get_current_session()
if sess is None or sess.session_key is None:
request = get_current_request()
if request is None or request.session is None or request.session.session_key is None:
return f(*args, **kwargs)
sess_key = sess.session_key
sess_key = request.session.session_key
if sess_key not in sess_funs:
# lru_cache makes the job of memoization
# We store only the 512 latest data per session. It has to be enough.

View File

@ -36,7 +36,7 @@
"mask": 1,
"field": "",
"permanent": true,
"description": "Voir son compte utilisateur"
"description": "Voir son compte utilisateur⋅rice"
}
},
{
@ -68,7 +68,7 @@
"mask": 1,
"field": "",
"permanent": true,
"description": "Voir sa propre note d'utilisateur"
"description": "Voir sa propre note d'utilisateur⋅rice"
}
},
{
@ -111,12 +111,12 @@
"note",
"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",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir les aliases des notes des clubs et des adhérents du club Kfet"
"description": "Voir les alias des notes des clubs et des adhérent⋅es du club BDE"
}
},
{
@ -627,7 +627,7 @@
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"permanent": true,
"description": "Voir les personnes qu'on a invitées"
}
},
@ -772,7 +772,7 @@
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir les adhérents du club"
"description": "Voir les adhérent⋅es du club"
}
},
{
@ -788,7 +788,7 @@
"mask": 2,
"field": "",
"permanent": false,
"description": "Ajouter un membre à un club"
"description": "Ajouter un⋅e membre à un club"
}
},
{
@ -852,7 +852,7 @@
"mask": 3,
"field": "",
"permanent": false,
"description": "Modifier n'importe quel utilisateur"
"description": "Modifier n'importe quel⋅le utilisateur⋅rice"
}
},
{
@ -868,7 +868,7 @@
"mask": 1,
"field": "",
"permanent": false,
"description": "Ajouter un utilisateur"
"description": "Ajouter un⋅e utilisateur⋅rice"
}
},
{
@ -977,7 +977,7 @@
],
"query": "[\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}]",
"type": "view",
"mask": 1,
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les transactions d'un club"
@ -1235,7 +1235,7 @@
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"permanent": true,
"description": "Voir le dernier WEI"
}
},
@ -1267,7 +1267,7 @@
"type": "add",
"mask": 1,
"field": "",
"permanent": false,
"permanent": true,
"description": "M'inscrire au dernier WEI"
}
},
@ -1284,7 +1284,7 @@
"mask": 1,
"field": "",
"permanent": false,
"description": "Inscrire un 1A au WEI"
"description": "Inscrire un⋅e 1A au WEI"
}
},
{
@ -1331,7 +1331,7 @@
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"permanent": true,
"description": "Voir ma propre inscription WEI"
}
},
@ -1379,7 +1379,7 @@
"type": "change",
"mask": 1,
"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"
}
},
@ -1427,7 +1427,7 @@
"type": "change",
"mask": 1,
"field": "birth_date",
"permanent": false,
"permanent": true,
"description": "Modifier la date de naissance de ma propre inscription WEI"
}
},
@ -1459,7 +1459,7 @@
"type": "change",
"mask": 1,
"field": "gender",
"permanent": false,
"permanent": true,
"description": "Modifier le genre de ma propre inscription WEI"
}
},
@ -1491,7 +1491,7 @@
"type": "change",
"mask": 1,
"field": "health_issues",
"permanent": false,
"permanent": true,
"description": "Modifier mes problèmes de santé de mon inscription WEI"
}
},
@ -1523,7 +1523,7 @@
"type": "change",
"mask": 1,
"field": "emergency_contact_name",
"permanent": false,
"permanent": true,
"description": "Modifier le nom du contact en cas d'urgence de mon inscription WEI"
}
},
@ -1555,7 +1555,7 @@
"type": "change",
"mask": 1,
"field": "emergency_contact_phone",
"permanent": false,
"permanent": true,
"description": "Modifier le téléphone du contact en cas d'urgence de mon inscription WEI"
}
},
@ -1572,7 +1572,7 @@
"mask": 1,
"field": "information_json",
"permanent": false,
"description": "Modifier les informations (sondage 1A, ...) d'une inscription WEI"
"description": "Modifier les informations (sondage 1A,) d'une inscription WEI"
}
},
{
@ -1699,7 +1699,7 @@
"type": "add",
"mask": 3,
"field": "",
"permanent": false,
"permanent": true,
"description": "Créer une adhésion WEI pour le dernier WEI"
}
},
@ -1956,7 +1956,7 @@
"mask": 1,
"field": "",
"permanent": true,
"description": "Voir mes activitées passées, même après la fin de l'adhésion BDE"
"description": "Voir mes activités passées, même après la fin de l'adhésion BDE"
}
},
{
@ -2003,7 +2003,7 @@
"type": "change",
"mask": 1,
"field": "clothing_cut",
"permanent": false,
"permanent": true,
"description": "Modifier ma coupe de vêtements de mon inscription WEI"
}
},
@ -2035,7 +2035,7 @@
"type": "change",
"mask": 1,
"field": "clothing_size",
"permanent": false,
"permanent": true,
"description": "Modifier la taille de vêtements de mon inscription WEI"
}
},
@ -2100,7 +2100,7 @@
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir n'importe quel utilisateur"
"description": "Voir n'importe quel⋅le utilisateur⋅rice"
}
},
{
@ -2228,7 +2228,7 @@
"mask": 1,
"field": "",
"permanent": false,
"description": "Créer une note d'utilisateur"
"description": "Créer une note d'utilisateur⋅rice"
}
},
{
@ -2243,7 +2243,7 @@
"type": "change",
"mask": 1,
"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+"
}
},
@ -2276,7 +2276,7 @@
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir tous les adhérents du club"
"description": "Voir toustes les adhérent⋅es du club"
}
},
{
@ -2292,7 +2292,7 @@
"mask": 3,
"field": "",
"permanent": false,
"description": "Ajouter un membre à n'importe quel club"
"description": "Ajouter un⋅e membre à n'importe quel club"
}
},
{
@ -2372,7 +2372,7 @@
"mask": 1,
"field": "name",
"permanent": false,
"description": "Modifier le nom d'une activité non validée dont on est l'auteur"
"description": "Modifier le nom d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2388,7 +2388,7 @@
"mask": 1,
"field": "description",
"permanent": false,
"description": "Modifier la description d'une activité non validée dont on est l'auteur"
"description": "Modifier la description d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2404,7 +2404,7 @@
"mask": 1,
"field": "location",
"permanent": false,
"description": "Modifier le lieu d'une activité non validée dont on est l'auteur"
"description": "Modifier le lieu d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2420,7 +2420,7 @@
"mask": 1,
"field": "activity_type",
"permanent": false,
"description": "Modifier le type d'une activité non validée dont on est l'auteur"
"description": "Modifier le type d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2436,7 +2436,7 @@
"mask": 1,
"field": "organizer",
"permanent": false,
"description": "Modifier l'organisateur d'une activité non validée dont on est l'auteur"
"description": "Modifier l'organisateur d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2452,7 +2452,7 @@
"mask": 1,
"field": "attendees_club",
"permanent": false,
"description": "Modifier le club attendu d'une activité non validée dont on est l'auteur"
"description": "Modifier le club attendu d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2468,7 +2468,7 @@
"mask": 1,
"field": "date_start",
"permanent": false,
"description": "Modifier la date de début d'une activité non validée dont on est l'auteur"
"description": "Modifier la date de début d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2484,7 +2484,7 @@
"mask": 1,
"field": "date_end",
"permanent": false,
"description": "Modifier la date de fin d'une activité non validée dont on est l'auteur"
"description": "Modifier la date de fin d'une activité non validée dont on est l'auteur⋅rice"
}
},
{
@ -2511,7 +2511,7 @@
"note",
"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",
"mask": 1,
"field": "is_active",
@ -2527,7 +2527,7 @@
"note",
"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",
"mask": 1,
"field": "inactivity_reason",
@ -2756,7 +2756,7 @@
"mask": 1,
"field": "",
"permanent": false,
"description": "Modifier n'importe quel utilisateur non encore inscrit"
"description": "Modifier n'importe quel⋅le utilisateur⋅rice non encore inscrit⋅e"
}
},
{
@ -2788,7 +2788,7 @@
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir tous les alias, y compris ceux des non adhérents"
"description": "Voir tous les alias, y compris ceux des non adhérent⋅es"
}
},
{
@ -2820,7 +2820,7 @@
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir n'importe quel utilisateur non encore inscrit"
"description": "Voir n'importe quel⋅le utilisateur⋅rice non encore inscrit⋅e"
}
},
{
@ -2847,12 +2847,12 @@
"auth",
"user"
],
"query": "{\"memberships__club__name\": \"BDE\", \"memberships__roles__name\": \"Adhérent BDE\", \"memberships__date_start__lte\": [\"today\"], \"memberships__date_end__gte\": [\"today\"]}",
"query": "{\"memberships__club__name\": \"BDE\", \"memberships__roles__name\": \"Adhérent⋅e BDE\", \"memberships__date_start__lte\": [\"today\"], \"memberships__date_end__gte\": [\"today\"]}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir n'importe quel utilisateur qui est adhérent BDE"
"description": "Voir n'importe quel⋅le utilisateur⋅rice qui est adhérent⋅e BDE"
}
},
{
@ -2871,18 +2871,227 @@
"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érent⋅es"
}
},
{
"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",
"pk": 1,
"fields": {
"for_club": 1,
"name": "Adh\u00e9rent BDE",
"name": "Adh\u00e9rent\u22c5e BDE",
"permissions": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
@ -2890,13 +3099,25 @@
11,
12,
13,
14,
15,
16,
17,
22,
48,
52,
126,
161,
162,
165
165,
186,
187,
188,
189,
190,
191,
195,
196
]
}
},
@ -2905,13 +3126,8 @@
"pk": 2,
"fields": {
"for_club": 2,
"name": "Adh\u00e9rent Kfet",
"name": "Adh\u00e9rent\u22c5e Kfet",
"permissions": [
6,
14,
15,
16,
17,
22,
34,
36,
@ -2942,7 +3158,9 @@
158,
159,
160,
179
179,
189,
190
]
}
},
@ -2977,7 +3195,7 @@
"pk": 5,
"fields": {
"for_club": null,
"name": "Pr\u00e9sident\u00b7e de club",
"name": "Pr\u00e9sident\u22c5e de club",
"permissions": [
50,
62,
@ -2991,7 +3209,7 @@
"pk": 6,
"fields": {
"for_club": null,
"name": "Tr\u00e9sorier\u00b7\u00e8re de club",
"name": "Tr\u00e9sorièr\u22c5e de club",
"permissions": [
59,
19,
@ -3010,7 +3228,9 @@
166,
167,
168,
182
182,
184,
185
]
}
},
@ -3019,7 +3239,7 @@
"pk": 7,
"fields": {
"for_club": 1,
"name": "Pr\u00e9sident\u00b7e BDE",
"name": "Pr\u00e9sident\u22c5e BDE",
"permissions": [
24,
25,
@ -3035,7 +3255,7 @@
"pk": 8,
"fields": {
"for_club": 1,
"name": "Tr\u00e9sorier\u00b7\u00e8re BDE",
"name": "Tr\u00e9sorièr\u22c5e BDE",
"permissions": [
23,
24,
@ -3048,6 +3268,7 @@
31,
32,
33,
43,
51,
53,
54,
@ -3089,7 +3310,10 @@
176,
177,
178,
183
188,
183,
186,
187
]
}
},
@ -3277,7 +3501,20 @@
180,
181,
182,
183
183,
184,
185,
186,
187,
188,
189,
190,
191,
192,
193,
194,
195,
196
]
}
},
@ -3304,6 +3541,7 @@
30,
31,
70,
72,
143,
166,
167,
@ -3336,7 +3574,8 @@
45,
46,
148,
149
149,
182
]
}
},
@ -3379,7 +3618,7 @@
"pk": 13,
"fields": {
"for_club": null,
"name": "Chef de bus",
"name": "Chef\u22c5fe de bus",
"permissions": [
22,
84,
@ -3397,7 +3636,7 @@
"pk": 14,
"fields": {
"for_club": null,
"name": "Chef d'\u00e9quipe",
"name": "Chef\u22c5fe d'\u00e9quipe",
"permissions": [
22,
84,
@ -3446,7 +3685,7 @@
"pk": 18,
"fields": {
"for_club": null,
"name": "Adhérent WEI",
"name": "Adhérent\u22c5e WEI",
"permissions": [
77,
87,
@ -3495,7 +3734,7 @@
"model": "permission.role",
"pk": 20,
"fields": {
"for_club": 2,
"for_club": 1,
"name": "PC Kfet",
"permissions": [
6,
@ -3511,6 +3750,8 @@
56,
57,
58,
70,
72,
135,
137,
143,

View File

@ -59,7 +59,7 @@ class InstancedPermission:
# Force insertion, no data verification, no trigger
obj._force_save = True
# We don't want to trigger any signal (log, ...)
# We don't want to trigger any signal (log,)
obj._no_signal = True
Model.save(obj, force_insert=True)
ret = self.model.model_class().objects.filter(self.query & Q(pk=0)).exists()
@ -227,7 +227,7 @@ class Permission(models.Model):
def compute_param(value, **kwargs):
"""
A parameter is given by a list. The first argument is the name of the parameter.
The parameters are the user, the club, and some classes (Note, ...)
The parameters are the user, the club, and some classes (Note,)
If there are more arguments in the list, then attributes are queried.
For example, ["user", "note", "balance"] will return the balance of the note of the user.
If an argument is a list, then this is interpreted with a function call:

View File

@ -45,7 +45,7 @@ class StrongDjangoObjectPermissions(DjangoObjectPermissions):
perms = self.get_required_object_permissions(request.method, model_cls)
# 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
# they have read permissions to see 403, or not, and simply see
# 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.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
@ -16,6 +16,9 @@ EXCLUDED = [
'contenttypes.contenttype',
'logs.changelog',
'migrations.migration',
'oauth2_provider.accesstoken',
'oauth2_provider.grant',
'oauth2_provider.refreshtoken',
'sessions.session',
]
@ -31,8 +34,8 @@ def pre_save_object(sender, instance, **kwargs):
if hasattr(instance, "_force_save") or hasattr(instance, "_no_signal"):
return
user = get_current_authenticated_user()
if user is None:
request = get_current_request()
if request is None:
# Action performed on shell is always granted
return
@ -45,7 +48,7 @@ def pre_save_object(sender, instance, **kwargs):
# We check if the user can change the model
# 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
# 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 old_value == new_value:
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(
_("You don't have the permission to change the field {field} on this instance of model"
" {app_label}.{model_name}.")
@ -66,7 +76,7 @@ def pre_save_object(sender, instance, **kwargs):
)
else:
# 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:
raise PermissionDenied(
@ -87,8 +97,8 @@ def pre_delete_object(instance, **kwargs):
# Don't check permissions on force-deleted objects
return
user = get_current_authenticated_user()
if user is None:
request = get_current_request()
if request is None:
# Action performed on shell is always granted
return
@ -97,7 +107,7 @@ def pre_delete_object(instance, **kwargs):
model_name = model_name_full[1]
# 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(
_("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))

View File

@ -8,7 +8,7 @@ from django.urls import reverse_lazy
from django.utils.html import format_html
from django_tables2 import A
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
@ -20,7 +20,7 @@ class RightsTable(tables.Table):
def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail.
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>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
return s
@ -28,7 +28,7 @@ class RightsTable(tables.Table):
def render_club(self, value):
# If the user has the right, link the displayed user with the page of its detail.
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>",
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
@ -36,13 +36,13 @@ class RightsTable(tables.Table):
def render_roles(self, record):
# If the user has the right to manage the roles, display the link to manage them
roles = record.roles.filter((~(Q(name="Adhérent BDE")
| Q(name="Adhérent Kfet")
roles = record.roles.filter((~(Q(name="Adhérent⋅e BDE")
| Q(name="Adhérent⋅e Kfet")
| Q(name="Membre de club")
| Q(name="Bureau de club"))
& Q(weirole__isnull=True))).all()
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 + "</a>")
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
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import stringfilter
from django import template
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
from permission.backends import PermissionBackend
from note_kfet.middlewares import get_current_request
from ..backends import PermissionBackend
@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.
"""
user = get_current_authenticated_user()
session = get_current_session()
if user is None or isinstance(user, AnonymousUser):
request = get_current_request()
user = request.user
session = request.session
if user is None or not user.is_authenticated:
return False
elif user.is_superuser and session.get("permission_mask", -1) >= 42:
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.
"""
user = get_current_authenticated_user()
request = get_current_request()
user = request.user
spl = model_name.split(".")
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, t))
if user is None or isinstance(user, AnonymousUser):
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(request, ct, t))
if user is None or not user.is_authenticated:
return qs.none()
if fetch:
qs = qs.all()
@ -49,7 +51,7 @@ def model_list_length(model_name, t="view"):
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()

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⋅e 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⋅e 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
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.urls import path
from permission.views import RightsView
from .views import RightsView, ScopesView
app_name = 'permission'
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
# SPDX-License-Identifier: GPL-3.0-or-later
from collections import OrderedDict
from datetime import date
from django.contrib.auth.mixins import LoginRequiredMixin
@ -28,7 +28,7 @@ class ProtectQuerysetMixin:
"""
def get_queryset(self, filter_permissions=True, **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
def get_object(self, queryset=None):
@ -53,7 +53,7 @@ class ProtectQuerysetMixin:
# We could also delete the field, but some views might be affected.
meta = form.instance._meta
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):
form.fields[key].widget = HiddenInput()
@ -101,7 +101,7 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView):
# noinspection PyProtectedMember
app_label, model_name = model_class._meta.app_label, model_class._meta.model_name.lower()
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 "
"{app_label}.{model_name}.").format(app_label=app_label, model_name=model_name))
return super().dispatch(request, *args, **kwargs)
@ -131,8 +131,8 @@ class RightsView(TemplateView):
special_memberships = Membership.objects.filter(
date_start__lte=date.today(),
date_end__gte=date.today(),
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent BDE")
| Q(name="Adhérent Kfet")
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent⋅e BDE")
| Q(name="Adhérent⋅e Kfet")
| Q(name="Membre de club")
| Q(name="Bureau de club"))
& Q(weirole__isnull=True))))\
@ -143,3 +143,26 @@ class RightsView(TemplateView):
prefix="superusers-")
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):
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 "
"account, you will have to pay the BDE membership."),
required=False,

View File

@ -66,9 +66,11 @@ class UserCreateView(CreateView):
profile_form.instance.user = user
profile = profile_form.save(commit=False)
user.profile = profile
user._force_save = True
user.save()
user.refresh_from_db()
profile.user = user
profile._force_save = True
profile.save()
user.profile.send_email_validation_link()
@ -83,6 +85,9 @@ class UserCreateView(CreateView):
return super().form_valid(form)
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')
@ -110,7 +115,9 @@ class UserValidateView(TemplateView):
self.validlink = True
user.is_active = user.profile.registration_valid or user.is_superuser
user.profile.email_confirmed = True
user._force_save = True
user.save()
user.profile._force_save = True
user.profile.save()
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
kfet = Club.objects.get(name="Kfet")
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["declare_soge_account"] = SogeCredit.objects.filter(user=user).exists()
@ -348,7 +352,7 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
membership._soge = True
membership.save()
membership.refresh_from_db()
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
membership.save()
if join_kfet:
@ -362,7 +366,7 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
membership._soge = True
membership.save()
membership.refresh_from_db()
membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
membership.roles.add(Role.objects.get(name="Adhérent⋅e Kfet"))
membership.save()
if soge:
@ -387,7 +391,7 @@ class FutureUserInvalidateView(ProtectQuerysetMixin, LoginRequiredMixin, View):
Delete the pre-registered user which id is given in the URL.
"""
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"])
# Delete associated soge credits before
SogeCredit.objects.filter(user=user).delete()

View File

@ -1,6 +1,6 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import transaction
from rest_framework import serializers
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.
"""
@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:
model = SogeCredit
fields = '__all__'

View File

@ -4,11 +4,12 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms
from django.contrib.auth.models import User
from django.db import transaction
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):
@ -161,3 +162,19 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
class Meta:
model = SpecialTransactionProxy
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
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from datetime import date
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
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.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
from note.models import NoteSpecial, SpecialTransaction, MembershipTransaction, NoteUser
@ -286,6 +288,7 @@ class SogeCredit(models.Model):
transactions = models.ManyToManyField(
MembershipTransaction,
related_name="+",
blank=True,
verbose_name=_("membership transactions"),
)
@ -302,13 +305,60 @@ class SogeCredit(models.Model):
@property
def amount(self):
return self.credit_transaction.total if self.valid \
else sum(transaction.total for transaction in self.transactions.all()) + 8000
if self.valid:
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):
"""
Invalidating a Société générale delete the transaction of the bank if it was already created.
Treasurers must know what they do, With Great Power Comes Great Responsibility...
Treasurers must know what they do, With Great Power Comes Great Responsibility
"""
if self.valid:
self.credit_transaction.valid = False
@ -365,13 +415,14 @@ class SogeCredit(models.Model):
self.credit_transaction.amount = self.amount
self.credit_transaction._force_save = True
self.credit_transaction.save()
super().save(*args, **kwargs)
return super().save(*args, **kwargs)
def delete(self, **kwargs):
"""
Deleting a SogeCredit is equivalent to say that the Société générale didn't pay.
Treasurers must know what they do, this is difficult to undo this operation.
With Great Power Comes Great Responsibility...
With Great Power Comes Great Responsibility
"""
total_fee = sum(transaction.total for transaction in self.transactions.all() if not transaction.valid)
@ -392,6 +443,7 @@ class SogeCredit(models.Model):
# was opened after the validation of the account.
self.credit_transaction.valid = False
self.credit_transaction.reason += " (invalide)"
self.credit_transaction._force_save = True
self.credit_transaction.save()
super().delete(**kwargs)

View File

@ -3,6 +3,7 @@
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load crispy_forms_filters %}
{% load i18n %}
{% block content %}
@ -27,7 +28,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }}
</h3>
<div class="card-body">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
<div class="input-group">
<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">
<label for="invalid_only" class="form-check-label">
<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 %}
</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 %}
{% block extrajavascript %}
<script type="text/javascript">
$(document).ready(function () {
let old_pattern = null;
let searchbar_obj = $("#searchbar");
let invalid_only_obj = $("#invalid_only");
@ -69,6 +96,22 @@ SPDX-License-Identifier: GPL-3.0-or-later
searchbar_obj.keyup(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>
{% endblock %}

View File

@ -25,7 +25,8 @@ from note_kfet.settings.base import BASE_DIR
from permission.backends import PermissionBackend
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 .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable, SogeCreditTable
@ -107,7 +108,7 @@ class InvoiceListView(LoginRequiredMixin, SingleTableView):
name="",
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."))
return super().dispatch(request, *args, **kwargs)
@ -194,7 +195,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
def get(self, request, **kwargs):
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
try:
@ -259,7 +260,7 @@ class RemittanceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
context["table"] = RemittanceTable(
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())
return context
@ -281,7 +282,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
remittance_type_id=1,
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."))
return super().dispatch(request, *args, **kwargs)
@ -290,7 +291,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
opened_remittances = RemittanceTable(
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-",
)
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(
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-",
)
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(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance=None).filter(
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
exclude=('remittance_remove', ),
prefix="no-remittance-",
)
@ -317,7 +318,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
with_remittance_tr = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
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', ),
prefix="with-remittance-",
)
@ -342,7 +343,7 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView)
context = super().get_context_data(**kwargs)
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(
data=data,
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
@ -433,6 +434,11 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
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):
"""

View File

@ -1,10 +1,10 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# 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
__all__ = [
'WEIForm', 'WEIRegistrationForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
'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.forms import CheckboxSelectMultiple
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 ..models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership, WEIRole
@ -27,6 +27,15 @@ class WEIForm(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:
model = WEIRegistration
exclude = ('wei', )
@ -36,11 +45,10 @@ class WEIRegistrationForm(forms.ModelForm):
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ...',
'placeholder': 'Nom ',
},
),
"birth_date": DatePickerInput(options={'defaultDate': '2000-01-01',
'minDate': '1900-01-01',
"birth_date": DatePickerInput(options={'minDate': '1900-01-01',
'maxDate': '2100-01-01'}),
}
@ -66,7 +74,7 @@ class WEIChooseBusForm(forms.Form):
queryset=WEIRole.objects.filter(~Q(name="1A")),
label=_("WEI Roles"),
help_text=_("Select the roles that you are interested in."),
initial=WEIRole.objects.filter(name="Adhérent WEI").all(),
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
widget=CheckboxSelectMultiple(),
)
@ -109,7 +117,8 @@ class WEIMembershipForm(forms.ModelForm):
def clean(self):
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."))
return cleaned_data
@ -121,20 +130,34 @@ class WEIMembershipForm(forms.ModelForm):
Bus,
attrs={
'api_url': '/api/wei/bus/',
'placeholder': 'Bus ...',
'placeholder': 'Bus',
}
),
"team": Autocomplete(
BusTeam,
attrs={
'api_url': '/api/wei/team/',
'placeholder': 'Équipe ...',
'placeholder': 'Équipe',
},
resetable=True,
),
}
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 Meta:
model = Bus
@ -144,7 +167,7 @@ class BusForm(forms.ModelForm):
WEIClub,
attrs={
'api_url': '/api/wei/club/',
'placeholder': 'WEI ...',
'placeholder': 'WEI',
},
),
}
@ -159,7 +182,7 @@ class BusTeamForm(forms.ModelForm):
Bus,
attrs={
'api_url': '/api/wei/bus/',
'placeholder': 'Bus ...',
'placeholder': 'Bus',
},
),
"color": ColorWidget(),

View File

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

View File

@ -1,12 +1,12 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# 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.forms import Form
from ...models import WEIClub, WEIRegistration, Bus
from ...models import WEIClub, WEIRegistration, Bus, WEIMembership
class WEISurveyInformation:
@ -50,6 +50,20 @@ class WEIBusInformation:
self.bus.information = d
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:
"""
@ -76,14 +90,20 @@ class WEISurveyAlgorithm:
"""
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
def get_buses(cls) -> QuerySet:
"""
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
def get_bus_information(cls, bus):
@ -125,7 +145,10 @@ class WEISurvey:
"""
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
def get_survey_information_class(cls):
@ -192,3 +215,23 @@ class WEISurvey:
self.information.selected_bus_pk = bus.pk
self.information.selected_bus_name = bus.name
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
# 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 Bus
from ...models import WEIMembership
WORDS = [
'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:
information.seed = int(1000 * time.time())
information.save(registration)
registration._force_save = True
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:
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 = 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
@ -124,12 +139,46 @@ class WEISurvey2021(WEISurvey):
"""
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):
"""
The algorithm class for the year 2021.
For now, the algorithm is quite simple: the selected bus is the chosen bus.
TODO: Improve this algorithm.
We use Gale-Shapley algorithm to attribute 1y students into buses.
"""
@classmethod
@ -140,9 +189,105 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
def get_bus_information_class(cls):
return WEIBusInformation2021
def run_algorithm(self):
for registration in self.get_registrations():
survey = self.get_survey_class()(registration)
rng = Random(survey.information.seed)
survey.select_bus(rng.choice(Bus.objects.all()))
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
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

@ -84,5 +84,5 @@ class Command(BaseCommand):
s += sep + user.profile.section_generated
s += sep + bus.name
s += sep + (team.name if team else "--")
s += sep + ", ".join(role.name for role in membership.roles.filter(~Q(name="Adhérent WEI")).all())
s += sep + ", ".join(role.name for role in membership.roles.filter(~Q(name="Adhérent⋅e WEI")).all())
self.stdout.write(s)

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.db import transaction
from wei.forms import CurrentSurvey
from ...forms import CurrentSurvey
class Command(BaseCommand):
@ -24,17 +24,31 @@ class Command(BaseCommand):
sid = transaction.savepoint()
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']
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, members in per_bus.items():
output.write(bus.name + "\n")
output.write("=" * len(bus.name) + "\n")
_order = -1
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")
if not options['doit']:

View File

@ -7,6 +7,7 @@ from datetime import date
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from member.models import Club, Membership
@ -98,6 +99,13 @@ class Bus(models.Model):
"""
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):
return self.name
@ -145,7 +153,7 @@ class BusTeam(models.Model):
class WEIRole(Role):
"""
A Role for the WEI can be bus chief, team chief, free electron, ...
A Role for the WEI can be bus chief, team chief, free electron,
"""
class Meta:
@ -250,7 +258,7 @@ class WEIRegistration(models.Model):
@property
def information(self):
"""
The information about the registration (the survey for the new members, the bus for the older members, ...)
The information about the registration (the survey for the new members, the bus for the older members,)
are stored in a dictionary that can evolve following the years. The dictionary is stored as a JSON string.
"""
return json.loads(self.information_json)
@ -262,6 +270,33 @@ class WEIRegistration(models.Model):
"""
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
def is_validated(self):
try:
@ -337,8 +372,19 @@ class WEIMembership(Membership):
# to treasurers.
transaction.refresh_from_db()
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()
transaction.save()
soge_credit.transactions.add(transaction)
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
import django_tables2 as tables
from django.db.models import Q
from django.urls import reverse_lazy
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
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 .models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership
@ -43,6 +44,7 @@ class WEIRegistrationTable(tables.Table):
edit = tables.LinkColumn(
'wei:wei_update_registration',
orderable=False,
args=[A('pk')],
verbose_name=_("Edit"),
text=_("Edit"),
@ -53,18 +55,14 @@ class WEIRegistrationTable(tables.Table):
}
}
)
validate = tables.LinkColumn(
'wei:validate_registration',
args=[A('pk')],
validate = tables.Column(
verbose_name=_("Validate"),
text=_("Validate"),
orderable=False,
accessor=A('pk'),
attrs={
'th': {
'id': 'validate-membership-header'
},
'a': {
'class': 'btn btn-success',
'data-type': 'validate-membership'
}
}
)
@ -72,6 +70,7 @@ class WEIRegistrationTable(tables.Table):
delete = tables.LinkColumn(
'wei:wei_delete_registration',
args=[A('pk')],
orderable=False,
verbose_name=_("delete"),
text=_("Delete"),
attrs={
@ -87,7 +86,7 @@ class WEIRegistrationTable(tables.Table):
def render_validate(self, record):
hasperm = PermissionBackend.check_perm(
get_current_authenticated_user(), "wei.add_weimembership", WEIMembership(
get_current_request(), "wei.add_weimembership", WEIMembership(
club=record.wei,
user=record.user,
date_start=date.today(),
@ -96,10 +95,26 @@ class WEIRegistrationTable(tables.Table):
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):
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>")
class Meta:
@ -108,7 +123,8 @@ class WEIRegistrationTable(tables.Table):
}
model = WEIRegistration
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 = {
'class': 'table-row',
'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):
name = tables.LinkColumn(
'wei:manage_bus',
@ -230,3 +275,66 @@ class BusTeamTable(tables.Table):
'id': lambda record: "row-" + str(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>
{% 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 %}
{% block extrajavascript %}

View File

@ -2,6 +2,7 @@
\usepackage{fontspec}
\usepackage[margin=1.5cm]{geometry}
\usepackage{longtable}
\begin{document}
\begin{center}
@ -19,7 +20,7 @@
\begin{center}
\footnotesize
\begin{tabular}{ccccccccc}
\begin{longtable}{ccccccccc}
\textbf{Nom} & \textbf{Prénom} & \textbf{Date de naissance} & \textbf{Genre} & \textbf{Section}
& \textbf{Bus} & \textbf{Équipe} & \textbf{Rôles} \\
{% for membership in memberships %}
@ -27,20 +28,20 @@
& {{ 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 }} \\
{% endfor %}
\end{tabular}
\end{longtable}
\end{center}
\footnotesize
Section = Année à l'ENS + code du département
\begin{center}
\begin{tabular}{ccccccccc}
\begin{longtable}{ccccccccc}
\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 \\
\hline
\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
\end{tabular}
\end{longtable}
\end{center}
\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>
<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>
<dd class="col-xl-6">{{ registration.clothing_cut }}</dd>

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_content %}
<div class="card">
<div class="card-body">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note/bus/équipe ...">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note/bus/équipe">
<hr>
<div id="memberships_table">
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card-footer text-center">
<a href="{% url 'wei:wei_registrations' pk=club.pk %}">
<button class="btn btn-block btn-info">{% trans "View unvalidated registrations..." %}</button>
<button class="btn btn-block btn-info">{% trans "View unvalidated registrations" %}</button>
</a>
<hr>
<a href="{% url 'wei:wei_memberships_pdf' wei_pk=club.pk %}" data-turbolinks="false">

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_content %}
<div class="card">
<div class="card-body">
<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">
<hr>
<div id="registrations_table">
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card-footer text-center">
<a href="{% url 'wei:wei_memberships' pk=club.pk %}">
<button class="btn btn-block btn-info">{% trans "View validated memberships..." %}</button>
<button class="btn btn-block btn-info">{% trans "View validated memberships" %}</button>
</a>
</div>
</div>

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.utils import timezone
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 ..api.views import BusViewSet, BusTeamViewSet, WEIClubViewSet, WEIMembershipViewSet, WEIRegistrationViewSet, \
@ -84,6 +84,13 @@ class TestWEIRegistration(TestCase):
wei=self.wei,
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(
name="Test Team",
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(
wei=self.wei.id,
name="Create Bus Test",
size=50,
description="This bus was created.",
information_json="{}",
))
qs = Bus.objects.filter(name="Create Bus Test")
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(
name="Update Bus Test",
size=40,
description="This bus was updated.",
information_json="{}",
))
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)
@ -291,6 +302,7 @@ class TestWEIRegistration(TestCase):
self.assertEqual(response.status_code, 200)
user = User.objects.create(username="toto", email="toto@example.com")
NoteUser.objects.create(user=user)
# Try with an invalid form
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",
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)
# 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)))
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")
NoteUser.objects.create(user=user)
response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict(
user=user.id,
soge_credit=True,
@ -456,6 +466,24 @@ class TestWEIRegistration(TestCase):
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)
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):
"""
Test display the end page of a survey.
@ -491,7 +519,7 @@ class TestWEIRegistration(TestCase):
emergency_contact_phone='+33600000000',
bus=[self.bus.id],
team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent WEI").all()],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
information_json=self.registration.information_json,
)
)
@ -545,7 +573,7 @@ class TestWEIRegistration(TestCase):
emergency_contact_phone='+33600000000',
bus=[self.bus.id],
team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent WEI").all()],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
information_json=self.registration.information_json,
)
)
@ -754,62 +782,7 @@ class TestDefaultWEISurvey(TestCase):
WEISurvey.update_form(None, None)
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
self.assertEqual(CurrentSurvey.get_year(), 2021)
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()
self.assertEqual(CurrentSurvey.get_year(), 2022)
class TestWeiAPI(TestAPI):

View File

@ -3,12 +3,11 @@
from django.urls import path
from .views import CurrentWEIDetailView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView,\
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView,\
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView,\
WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, WEIDeleteRegistrationView,\
WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
app_name = 'wei'
urlpatterns = [
@ -24,6 +23,7 @@ urlpatterns = [
name="wei_memberships_bus_pdf"),
path('detail/<int:wei_pk>/memberships/pdf/<int:bus_pk>/<int:team_pk>/', MemberListRenderView.as_view(),
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('manage-bus/<int:pk>/', BusManageView.as_view(), name="manage_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>/end/', WEISurveyEndView.as_view(), name="wei_survey_end"),
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 tempfile import mkdtemp
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Q, Count
from django.db.models.functions.text import Lower
from django.forms import HiddenInput
from django.http import HttpResponse
from django.http import HttpResponse, Http404
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse_lazy
@ -32,8 +32,10 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms.registration import WEIChooseBusForm
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembershipForm, CurrentSurvey
from .tables import WEITable, WEIRegistrationTable, BusTable, BusTeamTable, WEIMembershipTable
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \
WEIMembershipForm, CurrentSurvey
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
WEIRegistration1ATable, WEIMembershipTable
class CurrentWEIDetailView(LoginRequiredMixin, RedirectView):
@ -57,7 +59,7 @@ class WEIListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
def get_context_data(self, **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="",
email="weiclub@example.com",
year=0,
@ -112,7 +114,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
club = context["club"]
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')
history_table = HistoryTable(club_transactions, prefix="history-")
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=club,
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.paginate(per_page=20, page=self.request.GET.get('membership-page', 1))
context['member_list'] = membership_table
pre_registrations = WEIRegistration.objects.filter(
PermissionBackend.filter_queryset(self.request.user, WEIRegistration, "view")).filter(
PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter(
membership=None,
wei=club
)
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
my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user)
@ -142,7 +144,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
my_registration = None
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")
bus_table = BusTable(data=buses, prefix="bus-")
context['buses'] = bus_table
@ -167,7 +169,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
emergency_contact_phone="No",
)
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.
empty_old_registration = WEIRegistration(
@ -180,16 +182,20 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
emergency_contact_phone="No",
)
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(
wei=club,
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()
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
@ -222,7 +228,7 @@ class WEIMembershipsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
| Q(team__name__iregex=pattern)
)
return qs[:20]
return qs
def get_context_data(self, **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))
)
return qs[:20]
return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -370,13 +376,13 @@ class BusManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["club"] = self.object.wei
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")
teams_table = BusTeamTable(data=teams, prefix="team-")
context["teams"] = teams_table
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.paginate(per_page=20, page=self.request.GET.get("membership-page", 1))
context["memberships"] = memberships_table
@ -469,7 +475,7 @@ class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["club"] = self.object.bus.wei
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.paginate(per_page=20, page=self.request.GET.get("membership-page", 1))
context["memberships"] = memberships_table
@ -487,9 +493,16 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
def get_sample_object(self):
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(
wei=wei,
user=self.request.user,
user=user,
first_year=True,
birth_date="1970-01-01",
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
if today >= wei.date_start or today < wei.membership_start:
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)
def get_context_data(self, **kwargs):
@ -538,6 +556,12 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
" participated to a WEI."))
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)
def get_success_url(self):
@ -555,9 +579,16 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
def get_sample_object(self):
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(
wei=wei,
user=self.request.user,
user=user,
first_year=True,
birth_date="1970-01-01",
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
if today >= wei.date_start or today < wei.membership_start:
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)
def get_context_data(self, **kwargs):
@ -627,6 +663,12 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
form.instance.information = information
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)
def get_success_url(self):
@ -655,26 +697,19 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
context["club"] = self.object.wei
if self.object.is_validated:
membership_form = WEIMembershipForm(instance=self.object.membership,
data=self.request.POST if self.request.POST else None)
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"]
membership_form = self.get_membership_form(instance=self.object.membership,
data=self.request.POST)
context["membership_form"] = membership_form
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(
self.request.POST if self.request.POST else dict(
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(),
)
self.request.POST if self.request.POST else d
)
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"])
@ -690,21 +725,35 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
def get_form(self, form_class=None):
form = super().get_form(form_class)
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"]
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
def form_valid(self, form):
# If the membership is already validated, then we update the bus and the team (and the roles)
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():
return self.form_invalid(form)
membership_form.save()
# 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(
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)
if not choose_bus_form.is_valid():
return self.form_invalid(form)
@ -726,7 +775,7 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
survey = CurrentSurvey(self.object)
if not survey.is_complete():
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,
user=self.object.user,
date_start=date.today(),
@ -753,7 +802,7 @@ class WEIDeleteRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Delete
if today > wei.membership_end:
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."))
return super().dispatch(request, *args, **kwargs)
@ -772,7 +821,6 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
Validate WEI Registration
"""
model = WEIMembership
form_class = WEIMembershipForm
extra_context = {"title": _("Validate WEI registration")}
def get_sample_object(self):
@ -818,25 +866,22 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
date_start__gte=bde.membership_start,
).exists()
fee = registration.wei.membership_fee_paid if registration.user.profile.paid \
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
context["fee"] = registration.fee
form = context["form"]
if registration.soge_credit:
form.fields["credit_amount"].initial = fee
form.fields["credit_amount"].initial = registration.fee
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
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):
form = super().get_form(form_class)
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
@ -852,6 +897,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form.fields["bank"].disabled = True
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)
if registration.first_year:
# Use the results of the survey to fill initial data
@ -869,7 +916,7 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form["team"].initial = BusTeam.objects.get(pk=information["preferred_team_pk"][0])
if "preferred_roles_pk" in information:
form["roles"].initial = WEIRole.objects.filter(
Q(pk__in=information["preferred_roles_pk"]) | Q(name="Adhérent WEI")
Q(pk__in=information["preferred_roles_pk"]) | Q(name="Adhérent⋅e WEI")
).all()
return form
@ -959,12 +1006,11 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
membership.roles.set(WEIRole.objects.filter(name="1A").all())
membership.save()
ret = super().form_valid(form)
membership.save()
membership.refresh_from_db()
membership.roles.add(WEIRole.objects.get(name="Adhérent WEI"))
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
return ret
return super().form_valid(form)
def get_success_url(self):
self.object.refresh_from_db()
@ -1058,7 +1104,7 @@ class MemberListRenderView(LoginRequiredMixin, View):
"""
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(
Lower('bus__name'),
Lower('team__name'),
@ -1131,3 +1177,65 @@ class MemberListRenderView(LoginRequiredMixin, View):
shutil.rmtree(tmp_dir)
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, ))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -23,18 +23,18 @@ Pages de l'API
Il suffit d'ajouter le préfixe ``/api/`` pour arriver sur ces pages.
* `models <basic#type-de-contenu>`_ : liste des différents modèles enregistrés en base de données
* `user <basic#utilisateur>`_ : liste des différents utilisateurs enregistrés
* `user <basic#utilisateur>`_ : liste des différent⋅es utilisateur⋅rices enregistrés
* `members/profile <member#profil-utilisateur>`_ : liste des différents profils associés à des utilisateurs
* `members/club <member#club>`_ : liste des différents clubs enregistrés
* `members/membership <member#adhesion>`_ : liste des adhésions enregistrées
* `activity/activity <activity#activite>`_ : liste des activités recensées
* `activity/type <activity#type-d-activite>`_ : liste des différents types d'activités : pots, soirées de club, ...
* `activity/type <activity#type-d-activite>`_ : liste des différents types d'activités : pots, soirées de club,
* `activity/guest <activity#invite>`_ : liste des personnes invitées lors d'une activité
* `activity/entry <activity#entree>`_ : liste des entrées effectuées lors des activités
* `note/note <note#note>`_ : liste des notes enregistrées
* `note/alias <note#alias>`_ : liste des alias enregistrés
* `note/consumer <note#consommateur>`_ : liste des alias enregistrés avec leur note associée
* `note/transaction/category <note#categorie-de-transaction>`_ : liste des différentes catégories de boutons : soft, alcool, ...
* `note/transaction/category <note#categorie-de-transaction>`_ : liste des différentes catégories de boutons : soft, alcool,
* `note/transaction/transaction <note#transaction>`_ : liste des transactions effectuées
* `note/transaction/template <note#modele-de-transaction>`_ : liste des boutons enregistrés
* `treasury/invoice <treasury#facture>`_ : liste des factures générées
@ -69,7 +69,7 @@ S'authentifier
L'authentification peut se faire soit par session en se connectant via la page de connexion classique,
soit via un jeton d'authentification. Le jeton peut se récupérer via la page de son propre compte, en cliquant
sur le bouton « `Accès API <https://note.crans.org/accounts/manage-auth-token/>`_ ». Il peut être révoqué et regénéré
sur le bouton « `Accès API <https://note.crans.org/accounts/manage-auth-token/>`_ ». Il peut être révoqué et régénéré
en un clic.
Pour s'authentifier via ce jeton, il faut ajouter l'en-tête ``Authorization: Token <TOKEN>`` aux paramètres HTTP.
@ -111,7 +111,7 @@ Trois types de filtres sont implémentés :
Les filtres disponibles sont indiqués sur chacune des pages de documentation.
Le résultat est déjà par défaut filtré par droits : seuls les éléments que l'utilisateur à le droit de voir sont affichés.
Le résultat est déjà par défaut filtré par droits : seuls les éléments que l'utilisateur⋅rice a le droit de voir sont affichés.
Cela est possible grâce à la structure des permissions, générant justement des filtres de requêtes de base de données.
Une requête à l'adresse ``/api/<model>/<pk>/`` affiche directement les informations du modèle demandé au format JSON.
@ -120,14 +120,15 @@ POST
~~~~
Une requête POST permet d'ajouter des éléments. Cette requête n'est possible que sur la page ``/api/<model>/``,
la requête POST n'est pas supportée sur les pages de détails (car cette requête permet ... l'ajout).
la requête POST n'est pas supportée sur les pages de détails (car cette requête permet l'ajout).
Des exceptions sont faites sur certaines pages : les pages de logs et de contenttypes sont en lecture uniquement.
Les formats supportés sont multiples : ``application/json``, ``application/x-www-url-encoded``, ``multipart/form-data``.
Cela facilite l'envoi de requêtes. Le module construit ensuite l'instance du modèle et le sauvegarde dans la base de
données. L'application ``permission`` s'assure que l'utilisateur à le droit de faire ce type de modification. La réponse
renvoyée est l'objet enregistré au format JSON si l'ajout s'est bien déroulé, sinon un message d'erreur au format JSON.
données. L'application ``permission`` s'assure que l'utilisateur⋅rice a le droit de faire ce type de modification.
La réponse renvoyée est l'objet enregistré au format JSON si l'ajout s'est bien déroulé, sinon un message d'erreur au
format JSON.
PATCH
~~~~~
@ -205,10 +206,10 @@ Une reqête OPTIONS affiche l'ensemble des opérations possibles sur un modèle
* ``<METHOD>`` est le type de requête HTTP supporté (pour modification, inclus dans {``POST``, ``PUT``, ``PATCH``}).
* ``<FIELD_NAME>`` est le nom du champ dans le modèle concerné (exemple : ``id``)
* ``<TYPE>`` représente le type de données : ``integer``, ``string``, ``date``, ``choice``, ``field`` (pour les clés étrangères), ...
* ``<TYPE>`` représente le type de données : ``integer``, ``string``, ``date``, ``choice``, ``field`` (pour les clés étrangères),
* ``<REQUIRED>`` est un booléen indiquant si le champ est requis dans le modèle ou s'il peut être nul/vide.
* ``<READ_ONLY>`` est un booléen indiquant si le champ est accessible en lecture uniquement.
* ``<LABEL>`` représente le label du champ, son nom traduit, qui s'affiche dans le formulaire accessible sur l'API Web.
Des contraintes peuvent s'ajouter à cela selon les champs : taille maximale de chaînes de caractères, valeurs minimales
et maximales pour les entiers ...
et maximales pour les entiers

View File

@ -135,7 +135,7 @@ Options
"required": false,
"read_only": false,
"label": "Pay\u00e9",
"help_text": "Indique si l'utilisateur per\u00e7oit un salaire."
"help_text": "Indique si l'utilisateur⋅rice per\u00e7oit un salaire."
},
"ml_events_registration": {
"type": "choice",

View File

@ -507,7 +507,7 @@ Options
"required": false,
"read_only": false,
"label": "Premi\u00e8re ann\u00e9e",
"help_text": "Indique si l'utilisateur est nouveau dans l'\u00e9cole."
"help_text": "Indique si l'utilisateur⋅rice est nouvelleau dans l'\u00e9cole."
},
"information_json": {
"type": "string",
@ -520,7 +520,7 @@ Options
"type": "field",
"required": true,
"read_only": false,
"label": "Utilisateur"
"label": "Utilisateur⋅rice"
},
"wei": {
"type": "field",

View File

@ -3,20 +3,20 @@ Application Activités
L'application activités gère les différentes activités liées au BDE. Elle permet entre autres de créer des activités qui
peuvent être diffusées via des calendriers ou la mailing list d'événements. Elle permet aussi de réguler l'accès aux
événements, en s'assurant que leur note est positive. Elle permet enfin de gérer les invités.
événements, en s'assurant que leur note est positive. Elle permet enfin de gérer les invité⋅es.
Modèles
-------
L'application comporte 5 modèles : activités, types d'activité, invités, entrées et transactions d'invitation.
L'application comporte 5 modèles : activités, types d'activité, invité⋅es, entrées et transactions d'invitation.
Types d'activité
~~~~~~~~~~~~~~~~
Les activités sont triées par type (pots, soirées de club, ...), et chaque type regroupe diverses informations :
Les activités sont triées par type (pots, soirées de club,), et chaque type regroupe diverses informations :
* Nom du type
* Possibilité d'inviter des non-adhérents (booléen)
* Possibilité d'inviter des non-adhérent⋅es (booléen)
* Prix d'invitation (entier, centimes à débiter sur la note de l'hôte)
Activités
@ -26,7 +26,7 @@ Le modèle d'activité regroupe les informations liées à l'activité même :
* Nom de l'activité
* Description de l'activité
* Créateur, personne qui a proposé l'activité
* Créateur⋅rice, personne qui a proposé l'activité
* Club ayant organisé l'activité
* Note sur laquelle verser les crédits d'invitation (peut être nul si non concerné)
* Club invité (généralement le club Kfet)
@ -38,19 +38,19 @@ Le modèle d'activité regroupe les informations liées à l'activité même :
Entrées
~~~~~~~
Une instance de ce modèle est créé dès que quelqu'un est inscrit à l'activité. Sont stockées les informations suivantes :
Une instance de ce modèle est créé dès que quelqu'un⋅e est inscrit⋅e à l'activité. Sont stockées les informations suivantes :
* Activité concernée (clé étrangère)
* Heure d'entrée
* Note de la personne entrée, ou hôte s'il s'agit d'un invité (clé étrangère vers ``NoteUser``)
* Invité (``OneToOneField`` vers ``Guest``, ``None`` si c'est la personne elle-même qui rentre et non son invité)
* Note de la personne entrée, ou hôte s'il s'agit d'un⋅e invité⋅e (clé étrangère vers ``NoteUser``)
* Invité⋅e (``OneToOneField`` vers ``Guest``, ``None`` si c'est la personne elle-même qui rentre et non saon invité⋅e)
Il n'est pas possible de créer une entrée si la note est en négatif.
Invités
~~~~~~~
Invité⋅es
~~~~~~~~~
Les adhérents ont la possibilité d'inviter des amis. Pour cela, les différentes informations sont enregistrées :
Les adhérent⋅es ont la possibilité d'inviter des ami⋅es. Pour cela, les différentes informations sont enregistrées :
* Activité concernée (clé étrangère)
* Nom de famille
@ -60,7 +60,7 @@ Les adhérents ont la possibilité d'inviter des amis. Pour cela, les différent
Certaines contraintes s'appliquent :
* Une personne ne peut pas être invitée plus de 5 fois par an (coupe nom/prénom)
* Un adhérent ne peut pas inviter plus de 3 personnes par activité.
* Un⋅e adhérent⋅e ne peut pas inviter plus de 3 personnes par activité.
Transactions d'invitation
~~~~~~~~~~~~~~~~~~~~~~~~~
@ -83,15 +83,15 @@ UI
Création d'activités
~~~~~~~~~~~~~~~~~~~~
N'importe quel adhérent Kfet peut suggérer l'ajout d'une activité via un formulaire.
N'importe quel⋅le adhérent⋅e Kfet peut suggérer l'ajout d'une activité via un formulaire.
Gestion des activités
~~~~~~~~~~~~~~~~~~~~~
Les ayant-droit (Res[pot] et respos infos) peuvent valider les activités proposées. Ils peuvent également la modifier
si besoin. Ils peuvent enfin la déclarer ouvertes pour lancer l'accès aux entrées.
si besoin. Iels peuvent enfin la déclarer ouverte pour lancer l'accès aux entrées.
N'importe qui peut inviter des amis non adhérents, tant que les contraintes de nombre (un adhérent n'invite pas plus de
N'importe qui peut inviter des ami⋅es non adhérent⋅es, tant que les contraintes de nombre (un⋅e adhérent⋅e n'invite pas plus de
trois personnes par activité et une personne ne peut pas être invitée plus de 5 fois par an). L'invitation est
facturée à l'entrée.
@ -99,12 +99,12 @@ Entrées aux soirées
~~~~~~~~~~~~~~~~~~~
L'interface d'entrées est simple et ergonomique. Elle contient un champ de texte. À chaque fois que le champ est
modifié, un tableau est affiché comprenant la liste des invités et des adhérents dont le prénom, le nom ou un alias
modifié, un tableau est affiché comprenant la liste des invité⋅es et des adhérent⋅es dont le prénom, le nom ou un alias
de la note est acceptée par le texte entré.
En cliquant sur la ligne de la personne qui souhaite rentrée, s'il s'agit d'un adhérent, alors la personne est comptée
comme entrée à l'activité, sous réserve que sa note soit positive. S'il s'agit d'un invité, alors 3 boutons
En cliquant sur la ligne de la personne qui souhaite rentrer, s'il s'agit d'un⋅e adhérent⋅e, alors la personne est comptée
comme entrée à l'activité, sous réserve que sa note soit positive. S'il s'agit d'un⋅e invité⋅e, alors 3 boutons
apparaîssent, afin de régler la taxe d'invitation : l'un prélève directement depuis la note de l'hôte, les deux autres
permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent
automatiquement un crédit sur la note de l'hôte, puis une transaction (de type ``GuestTransaction``) est faite depuis
la note de l'hôte vers la note de l'organisateur de l'événement.
la note de l'hôte vers la note du club organisateur de l'événement.

View File

@ -1,5 +1,5 @@
Applications de la NoteKfet2020
===============================
Applications de la Note Kfet 2020
=================================
.. toctree::
:maxdepth: 2
@ -15,27 +15,26 @@ Applications de la NoteKfet2020
treasury
wei
La NoteKfet est un projet Django, décomposé en applications.
Certaines Applications sont développées uniquement pour ce projet, et sont indispensables,
d'autres sont packagesé et sont installées comme dépendances.
Enfin des fonctionnalités annexes ont été rajouté, mais ne sont pas essentiel au déploiement de la NoteKfet;
leur usage est cependant recommandé.
La Note Kfet 2020 est un projet Django, décomposé en applications.
Certaines applications sont développées uniquement pour ce projet, et sont indispensables,
d'autres sont packagées et sont installées comme dépendances.
Enfin, des fonctionnalités annexes ont été rajoutées, mais ne sont pas essentielles au déploiement de la Note Kfet 2020. Leur usage est cependant recommandé.
Le front utilise le framework Bootstrap4 et quelques morceaux de javascript custom.
L'affichage Web utilise le framework Bootstrap4 et quelques morceaux de JavaScript personnalisés.
Applications indispensables
---------------------------
* ``note_kfet`` :
Application "projet" de django, c'est ici que la config de la note est gérée.
Application "projet" de django, c'est ici que la configuration de la note est gérée.
* `Member <member>`_ :
Gestion des profils d'utilisateurs, des clubs et de leur membres.
Gestion des profils d'utilisateur⋅rices, des clubs et de leur membres.
* `Note <note>`_ :
Les notes associés a des utilisateurs ou des clubs.
Les notes associées à des utilisateur⋅rices ou des clubs.
* `Activity <activity>`_ :
La gestion des Activités (créations, gestion, entrée...)
La gestion des activités (créations, gestion, entrées,…)
* `Permission <permission>`_ :
Backend de droits, limites les pouvoirs des utilisateurs
Backend de droits, limites les pouvoirs des utilisateur⋅rices
* `API <../api>`_ :
API REST de la note, est notamment utilisée pour rendre la note dynamique
(notamment la page de conso)
@ -52,9 +51,9 @@ Applications packagées
`<https://django-polymorphic.readthedocs.io/en/stable/>`_
* ``crispy_forms``
Utiliser pour générer des forms avec bootstrap4
Utiliser pour générer des formulairess avec Bootstrap4
* ``django_tables2``
utiliser pour afficher des tables de données et les formater, en python plutôt qu'en HTML.
utiliser pour afficher des tables de données et les formater, en Python plutôt qu'en HTML.
* ``restframework``
Base de l'`API <../api>`_.
@ -63,11 +62,11 @@ Applications facultatives
* `Logs <logs>`_
Enregistre toute les modifications effectuées en base de donnée.
* ``cas-server``
Serveur central d'authenfication, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
* `Script <https://gitlab.crans.org/bde/nk20-scripts>`_
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc ...
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
* `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc
* `Treasury <treasury>`_ :
Interface de gestion pour les trésoriers, émission de facture, remise de chèque, statistiques ...
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques
* `WEI <wei>`_ :
Interface de gestion du WEI.

View File

@ -6,22 +6,22 @@ Chaque modification effectuée sur un modèle est enregistrée dans la base dans
Dès qu'un modèle veut être sauvegardé, deux signaux sont envoyés dans ``logs.signals`` : un avant et un après
la sauvegarde.
En pré-sauvegarde, on récupère l'ancienne version du modèle, si elle existe.
En post-sauvegarde, on récupère l'utilisateur et l'IP courants (voir ci-dessous), on convertit les modèles en JSON
En post-sauvegarde, on récupère l'utilisateur⋅rice et l'IP courant⋅es (voir ci-dessous), on convertit les modèles en JSON
et on enregistre une entrée ``Changelog`` dans la base de données.
Pour récupérer l'utilisateur et son IP, le middleware ``logs.middlewares.LogsMiddlewares`` récupère à chaque requête
l'utilisateur et l'adresse IP, et les stocke dans le processus courant, afin qu'ils puissent être
Pour récupérer l'utilisateur⋅rice et son IP, le middleware ``logs.middlewares.LogsMiddlewares`` récupère à chaque requête
l'utilisateur⋅rice et l'adresse IP, et les stocke dans le processus courant, afin qu'ils puissent être
récupérés par les signaux.
Si jamais la modification ne provient pas d'une requête Web, on suppose qu'elle vient d'une instruction
lancée avec ``manage.py``.
On récupère alors le nom de l'utilisateur dans l'interface de commandes, et si une note est associée à cet alias,
On récupère alors le nom de l'utilisateur⋅rice dans l'interface de commandes, et si une note est associée à cet alias,
alors on considère que c'est le détenteur de la note qui a effectué cette modification, sur l'adresse IP ``127.0.0.1``.
Sinon, le champ est laissé à ``None``.
Une entrée de ``Changelog`` contient les informations suivantes :
* Utilisateur (``ForeignKey`` vers ``User``, nullable)
* Utilisateur⋅rice (``ForeignKey`` vers ``User``, nullable)
* Adresse IP (``GenericIPAddressField``)
* Type de modèle enregistré (``ForeignKey`` vers ``Model``)
* Identifiant ``pk`` de l'instance enregistrée (``CharField``)
@ -54,4 +54,4 @@ Graphe
~~~~~~
.. image:: ../_static/img/graphs/logs.svg
:alt: Logs graphe
:alt: Logs graph

View File

@ -1,65 +1,65 @@
Application Member
==================
L'application ``member`` s'occcupe de la gestion des utilisateurs enregistrés.
L'application ``member`` s'occcupe de la gestion des utilisateur⋅rices enregistré⋅es.
Le model d'utilisateur ``django.contrib.auth.model.User`` est complété par un ``Profile`` utilisateur.
Le model d'utilisateur⋅rice ``django.contrib.auth.model.User`` est complété par un ``Profile`` utilisateur⋅rice.
Tous les utilisateurs peuvent être membre de ``Club``. Cela se traduit par une adhésion ``Membership``, dont les
Toustes les utilisateur⋅rices peuvent être membre de ``Club``. Cela se traduit par une adhésion ``Membership``, dont les
caractéristiques sont propres à chaque club.
En pratique, la NoteKfet possède au minimum deux Club: **Bde** et **Kfet** (instanciés via les fixtures). Et tous
les personnes à jour de cotisation sont membre à minima de Bde.
Être adhérent du club Kfet permet d'utiliser sa note pour consommer.
En pratique, la Note Kfet possède au minimum deux clubs : **Bde** et **Kfet** (instanciés
via les fixtures). Et toutes les personnes à jour de cotisation sont membre à minima de
BDE. Être adhérent⋅e du club Kfet permet d'utiliser sa note pour consommer.
Modèles
-------
Utilisateur
~~~~~~~~~~~
Utilisateur⋅rice
~~~~~~~~~~~~~~~~
Le modèle ``User`` est directement implémenté dans Django et n'appartient pas à l'application ``member``, mais il est
bon de rappeler à quoi ressemble ce modèle.
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur a été inscrit (*inutilisé dans la Note*)
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur.
* ``first_name`` : ``CharField``, prénom de l'utilisateur.
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur⋅rice a été inscrit (*inutilisé dans la Note*)
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur⋅rice.
* ``first_name`` : ``CharField``, prénom de l'utilisateur⋅rice.
* ``is_active`` : ``BooleanField``, indique si le compte est actif et peut se connecter.
* ``is_staff`` : ``BooleanField``, indique si l'utilisateur peut se connecter à l'interface Django-admin.
* ``is_superuser`` : ``BooleanField``, indique si l'utilisateur dispose de droits super-utilisateurs, permettant n'importe quelle action en base de donnée (lecture, ajout, modification, suppression).
* ``is_staff`` : ``BooleanField``, indique si l'utilisateur⋅rice peut se connecter à l'interface Django-admin.
* ``is_superuser`` : ``BooleanField``, indique si l'utilisateur⋅rice dispose de droits super-utilisateur⋅rices, permettant n'importe quelle action en base de donnée (lecture, ajout, modification, suppression).
* ``last_login`` : ``DateTimeField``, date et heure de dernière connexion.
* ``last_name`` : ``CharField``, nom de famille de l'utilisateur.
* ``password`` : ``CharField``, contient le hash du mot de passe de l'utilisateur. L'algorithme utilisé est celui par défaut de Django : PBKDF2 + HMAC + SHA256 avec 150000 itérations.
* ``username`` : ``CharField`` (unique), pseudo de l'utilisateur.
* ``last_name`` : ``CharField``, nom de famille de l'utilisateur⋅rice.
* ``password`` : ``CharField``, contient le hash du mot de passe de l'utilisateur⋅rice. L'algorithme utilisé est celui par défaut de Django : PBKDF2 + HMAC + SHA256 avec 150000 itérations.
* ``username`` : ``CharField`` (unique), pseudo de l'utilisateur⋅rice.
Profil
~~~~~~
Le modèle ``Profile`` contient un champ ``user`` de type ``OneToOneField``, ce qui permet de voir ce modèle comme une
extension du modèle ``User``, sans avoir à le réécrire. Il contient diverses informations personnelles sur
l'utilisateur, utiles pour l'adhésion au BDE :
l'utilisateur⋅rice, utiles pour l'adhésion au BDE :
* ``user`` : ``OneToOneField(User)``, utilisateur lié à ce profil
* ``address`` : ``CharField``, adresse physique de l'utilisateur
* ``paid`` : ``BooleanField``, indique si l'utilisateur normalien est rémunéré ou non (utile pour différencier les montants d'adhésion aux clubs)
* ``phone_number`` : ``CharField``, numéro de téléphone de l'utilisateur
* ``section`` : ``CharField``, section de l'ENS à laquelle apartient l'utilisateur (exemple : 1A0, ...)
* ``user`` : ``OneToOneField(User)``, utilisateur⋅rice lié à ce profil
* ``address`` : ``CharField``, adresse physique de l'utilisateur⋅rice
* ``paid`` : ``BooleanField``, indique si l'utilisateur⋅rice normalien⋅ne est rémunéré⋅e ou non (utile pour différencier les montants d'adhésion aux clubs)
* ``phone_number`` : ``CharField``, numéro de téléphone de l'utilisateur⋅rice
* ``section`` : ``CharField``, section de l'ENS à laquelle appartient l'utilisateur⋅rice (exemple : 1A0,)
Clubs
~~~~~
La gestion des clubs est une différence majeure avec la Note Kfet 2015. La Note gère ainsi les adhésions des
utilisateurs aux différents clubs.
utilisateur⋅rices aux différents clubs.
* ``parent_club`` : ``ForeignKey(Club)``. La présence d'un club parent force l'adhésion au club parent avant de pouvoir adhérer au dit club. Tout club qui n'est pas le club BDE doit avoir le club BDE dans son arborescence.
* ``email`` : ``EmailField``, adresse e-mail sur laquelle contacter le bureau du club.
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible d'adhérer à un club pour l'année suivante (si adhésions à l'année), en ignorant l'année. Par exemple, l'adhésion BDE est possible à partir du 31/08 par défaut, et c'est à cette date que les adhésions pour l'année future est possible.
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible d'adhérer à un club pour l'année suivante (si adhésions à l'année), en ignorant l'année. Par exemple, l'adhésion BDE est possible à partir du 01/08 par défaut, et c'est à cette date que les adhésions pour l'année future est possible.
* ``membership_end`` : ``DateField``, date maximale de fin d'adhésion. Pour le club BDE, il s'agit du 30/09 de l'année suivante. Si cette valeur vaut ``null``, la fin d'adhésion n'est pas limitée.
* ``membership_duration`` : ``PositiveIntegerField``, durée (en jours) maximale d'adhésion. Par exemple, le club BDE permet des adhésions maximales de 13 mois, soit 396 jours.
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un élève normalien (donc rémunéré) puisse adhérer.
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un étudiant normalien (donc non rémunéré) puisse adhérer.
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e élève normalien⋅ne (donc rémunéré⋅e) puisse adhérer.
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e étudiant⋅e normalien⋅ne (donc non rémunéré) puisse adhérer.
* ``name`` : ``CharField``, nom du club.
* ``require_memberships`` : ``BooleanField``, indique si le club est un vrai club BDE qui nécessite des adhésions de club, ou s'il s'agit d'une note "pot commun" (organisation d'une activité, note de département, ...)
* ``require_memberships`` : ``BooleanField``, indique si le club est un vrai club BDE qui nécessite des adhésions de club, ou s'il s'agit d'une note "pot commun" (organisation d'une activité, note de département,)
Adhésions
~~~~~~~~~
@ -67,16 +67,16 @@ Adhésions
Comme indiqué précédemment, la note gère les adhésions.
* ``club`` : ``ForeignKey(Club)``, club lié à l'adhésion.
* ``user`` : ``ForeignKey(User)``, utilisateur adhéré.
* ``user`` : ``ForeignKey(User)``, utilisateur⋅rice qui a adhéré.
* ``date_start`` : ``DateField``, date de début d'adhésion.
* ``date_end`` : ``DateField``, date de fin d'adhésion.
* ``fee`` : ``PositiveIntegerField``, montant de la cotisation payée.
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent.
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent⋅e.
Rôles
~~~~~
Comme indiqué le modèle des adhésions, les adhésions octroient des rôles aux adhérents, qui offrent des permissions
Comme indiqué le modèle des adhésions, les adhésions octroient des rôles aux adhérent⋅es, qui offrent des permissions
(cf ``RolesPermissions`` dans la page des permissions). Le modèle ``RolesPermissions`` possède un
``OneToOneField(Role)``, qui implémente les permissions des rôles. Le modèle ``Role`` à proprement parler ne contient
que le champ de son nom (``CharField``).
@ -88,7 +88,7 @@ Si le modèle ``MembershipTransaction`` appartient à l'application ``note``, il
Le modèle ``MembershipTransaction`` est une extension du modèle ``Transaction`` (application ``note``) qui est de type
polymorphique, et contient en plus des informations de base de la transaction un champ ``OneToOneField(Membership)``
faisant le lien entre l'adhésion et la transaction liée. Une adhésion club, si elle n'est pas gratuite,
génère en effet automatiquement une transaction de l'utilisateur vers le club (voir section adhésions).
génère en effet automatiquement une transaction de l'utilisateur⋅rice vers le club (voir section adhésions).
Graphe
------
@ -100,28 +100,28 @@ Adhésions
---------
La Note Kfet offre la possibilité aux clubs de gérer l'adhésion de leurs membres. En plus de réguler les cotisations
des adhérents, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
fonction occupée au sein d'un club (Trésorier de club, président de club, GCKfet, Res[pot], respo info, ...).
Une adhésion attribue à un adhérent ses rôles. Les rôles fournissent les permissions. Par exemple, le trésorier d'un
des adhérent⋅es, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
fonction occupée au sein d'un club (Trésorièr⋅e de club, président⋅e de club, GC Kfet, Res[pot], respo info,).
Une adhésion attribue à un⋅e adhérent⋅e ses rôles. Les rôles fournissent les permissions. Par exemple, læ trésorièr⋅e d'un
club a le droit de faire des transferts de et vers la note du club, tant que la source reste au-dessus de -50 €.
Une adhésion est considérée comme valide si la date du jour est comprise (au sens large) entre les dates de début et
de fin d'adhésion.
On peut ajouter une adhésion à un utilisateur dans un club à tout non adhérent de ce club. La personne en charge
d'adhérer quelqu'un choisit l'utilisateur, les rôles au sein du club et la date de début d'adhésion. Cette date de
On peut ajouter une adhésion à un⋅e utilisateur⋅rice dans un club à tout⋅e non adhérent⋅e de ce club. La personne en charge
d'adhérer quelqu'un choisit l'utilisateur⋅rice, les rôles au sein du club et la date de début d'adhésion. Cette date de
début d'adhésion doit se situer entre les champs ``club.membership_start`` et ``club.membership_end``,
si ces champs sont non nuls. Si ``club.parent_club`` n'est pas nul, l'utilisateur doit être membre de ce club.
Le montant de la cotisation est fixé en fonction du statut normalien de l'utilisateur (``club.membership_fee_paid``
centimes pour les élèves et ``club.membership_fee_unpaid`` centimes pour les étudiants). La date de fin est calculée
si ces champs sont non nuls. Si ``club.parent_club`` n'est pas nul, l'utilisateur⋅rice doit être membre de ce club.
Le montant de la cotisation est fixé en fonction du statut normalien de l'utilisateur⋅rice (``club.membership_fee_paid``
centimes pour les élèves et ``club.membership_fee_unpaid`` centimes pour les étudiant⋅es). La date de fin est calculée
comme ce qui suit :
* Si ``club.membership_duration`` est non nul, alors ``date_end`` = ``date_start`` + ``club.membership_duration``
* Sinon ``club``, ``date_end`` = ``date_start`` + 424242 jours (suffisant pour tenir au moins une vie)
* Si ``club.membership_end`` est non nul, alors ``date_end`` = min(``date_end``, ``club.membership_end``)
Si l'utilisateur n'est pas membre du club ``Kfet``, l'adhésion n'est pas possible si le solde disponible sur sa note est
Si l'utilisateur⋅rice n'est pas membre du club ``Kfet``, l'adhésion n'est pas possible si le solde disponible sur sa note est
insuffisant. Une fois toute ces contraintes vérifiées, l'adhésion est créée. Une transaction de type
``MembershipTransaction`` est automatiquement créée de la note de l'utilisateur vers la note du club, finalisant l'adhésion.
``MembershipTransaction`` est automatiquement créée de la note de l'utilisateur⋅rice vers la note du club, finalisant l'adhésion.
Réadhésions
~~~~~~~~~~~
@ -137,7 +137,7 @@ Il est possible de réadhérer si :
* Il n'y a pas encore de réadhésion (pas d'adhésion au même club vérifiant ``new_membership.date_start`` >= ``club.membership_start``)
Un bouton ``Réadhérer`` apparaît dans la liste des adhésions si le droit est permis et si ces contraintes sont vérifiées.
En réadhérant, une nouvelle adhésion est créée pour l'utilisateur avec les mêmes rôles, commençant le lendemain de la
En réadhérant, une nouvelle adhésion est créée pour l'utilisateur⋅rice avec les mêmes rôles, commençant le lendemain de la
date d'expiration de la précédente adhésion. Si on réadhère le 16 août pour une adhésion finissant le 30 septembre,
la nouvelle adhésion commencera le 1er octobre).

View File

@ -7,23 +7,23 @@ Affichage
La page de consommations est principalement une communication entre l'`API <../api>`_ et la page en JavaScript.
Elle est disponible à l'adresse ``/note/consos/``, et l'onglet n'est visible que pour ceux ayant le droit de voir au
moins un bouton. L'affichage, comme tout le reste de la page, est géré avec Boostrap 4.
Les boutons que l'utilisateur a le droit de voir sont triés par catégorie.
Les boutons que l'utilisateur⋅rice a le droit de voir sont triés par catégorie.
Sélection des consommations
---------------------------
Lorsque l'utilisateur commence à taper un nom de note, un appel à l'API sur la page ``/api/note/alias`` est fait,
récupérant les 20 premiers aliases en accord avec la requête. Quand l'utilisateur survole un alias, un appel à la page
Lorsque l'utilisateur⋅rice commence à taper un nom de note, un appel à l'API sur la page ``/api/note/alias`` est fait,
récupérant les 20 premiers aliases en accord avec la requête. Quand l'utilisateur⋅rice survole un alias, un appel à la page
``/api/note/note/<NOTE_ID>/`` est fait pour récupérer plus d'infos sur la note telles que le solde, le vrai nom de la
note et la photo, si toutefois l'utilisateur a le droit de voir ceci.
note et la photo, si toutefois l'utilisateur⋅rice a le droit de voir ceci.
L'utilisateur peut cliquer sur des aliases pour ajouter des émetteurs, et sur des boutons pour ajouter des consommations.
Cliquer dans la liste des émetteurs supprime l'élément sélectionné.
L'utilisateur⋅rice peut cliquer sur des aliases pour ajouter des émetteur⋅rices, et sur des boutons pour ajouter des consommations.
Cliquer dans la liste des émetteur⋅rices supprime l'élément sélectionné.
Il ya deux possibilités pour faire consommer des adhérents :
- En mode **consommation simple** (mode par défaut), les consommations sont débitées dès que émetteurs et consommations
Il ya deux possibilités pour faire consommer des adhérent⋅es :
- En mode **consommation simple** (mode par défaut), les consommations sont débitées dès que émetteur⋅rices et consommations
sont renseignées.
- En mode **consommation double**, l'utilisateur doit cliquer sur "Consommer !" pour débiter toutes les consommations.
- En mode **consommation double**, l'utilisateur⋅rice doit cliquer sur « **Consommer !** »" pour débiter toutes les consommations.
Débit des consommations
-----------------------
@ -71,7 +71,7 @@ des types. Il vaut `42` lors de la rédaction de cette documentation, mais pourr
Si une erreur survient lors de la requête (droits insuffisants), un message apparaîtra en haut de page.
Dans tous les cas, tous les champs sont réinitialisés.
L'historique et la balance de l'utilisateur sont ensuite mis à jour via jQuery, qui permet de recharger une partie de page Web.
L'historique et le solde de l'utilisateur⋅rice sont ensuite mis à jour via jQuery, qui permet de recharger une partie de page Web.
Validation/dévalidation des transactions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -86,4 +86,4 @@ une requête PATCH est faite à l'API sur l'adresse ``/api/note/transaction/tran
"valid": false
}
L'historique et la balance sont ensuite rafraîchis. Si une erreur survient, un message apparaîtra.
L'historique et le solde sont ensuite rafraîchis. Si une erreur survient, un message apparaîtra.

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