1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-21 18:08:21 +02:00

Compare commits

..

481 Commits

Author SHA1 Message Date
03d2d5f03e change -50€ to -20€ and doc 2023-08-22 21:51:02 +02:00
d2057a9f45 remove respo-info perm and change Prez BDE prem 2023-08-22 21:19:05 +02:00
6b1cd3ba7a manage self aliases for BDE member instead of kfet 2023-07-24 12:42:44 +02:00
9f114b8ca2 fixtures activities 2023-07-24 12:26:34 +02:00
e0132b6dc8 migration permission 2023-07-24 12:20:16 +02:00
31e67ae3f6 typo 2023-07-09 16:06:30 +02:00
2839d3de1e club facultatif pour un role lors du changement dans l'interface admin 2023-06-22 14:52:11 +02:00
30afa6da0a création d'une permission pour faire les crédits uniquement 2023-06-12 18:29:23 +02:00
84fc77696f see activities: BDE members instead of kfet 2023-06-05 19:04:19 +02:00
19fc620d1f see kfet members' note for respot 2023-06-05 17:26:49 +02:00
6bceb394c5 prez BDE sould see invoice list 2023-03-29 20:43:54 +02:00
62cf8f9d84 forgetted coma 2023-03-28 20:41:53 +02:00
2dd1c3fb89 change mask for some perm 2023-03-20 22:35:51 +01:00
c8665c5798 change permissions for role 2023-03-20 22:21:18 +01:00
e9f1b6f52d change permanent permissions 2023-03-20 17:19:14 +01:00
1d95ae4810 sort perm by number 2023-03-20 16:16:32 +01:00
84b16ab603 Merge branch 'SogeCreditDate' into 'main'
link SogeCredit to WEI by creation date instead of civil year

See merge request bde/nk20!206
2023-01-17 15:58:52 +01:00
6a1b51dbbf Merge branch 'api_pagination' into 'main'
Add custom pagination size as an API parameter

See merge request bde/nk20!205
2023-01-11 22:46:13 +01:00
c441a43a8b link SogeCredit to WEI by creation date instead of civil year 2023-01-10 21:40:03 +01:00
87f3b51b04 Add custom pagination size as an API parameter 2022-12-14 18:37:13 +01:00
0a853fd3e6 Merge branch 'permission_trez' into 'main'
fix trez perm

See merge request bde/nk20!204
2022-12-10 14:41:57 +01:00
c429734810 fix bug 2022-11-12 14:51:22 +01:00
5d759111b6 Merge branch 'weiWords' into 'main'
change wei words

See merge request bde/nk20!203
2022-09-05 13:24:24 +02:00
70baf7566c change wei words 2022-09-05 13:20:00 +02:00
eb355f547c Merge branch 'SogeNotForMembership' into 'main'
Soge not for membership

See merge request bde/nk20!202
2022-09-04 22:56:07 +02:00
7068170f18 fixing grammar in comments 2022-09-04 13:24:39 +02:00
45ee9a8941 Soge only payd WEI (not bde/kfet membership) 2022-09-04 12:52:40 +02:00
454ea19603 hide Soge during registration 2022-09-04 12:31:08 +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
802a6c68cb [WEI] Update survey words
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-26 00:11:24 +02:00
41a0b3a1c1 [WEI] Request bus size
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-25 23:26:57 +02:00
aa35724be2 Better display for WEI member list
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-23 19:00:26 +02:00
9086d33158 [WEI] Caution check is not required to validate registrations
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-23 18:51:34 +02:00
43d214b982 [WEI] Store seed in WEI Survey to add determinism in RNG
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-02 19:30:36 +02:00
b93e4a8d11 Current WEI year is 2021
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-02 19:22:07 +02:00
b9a9704061 [WEI] Prepare WEI 2021
No need to save WEI 2020 data because there weren't any WEI 2020

Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-02 18:22:19 +02:00
fee52f326a [WEI] Add dry mode in WEI algorithm command, output generated data
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-08-02 18:21:06 +02:00
317966d5c1 Merge branch 'l_eveil_du_nanax' into 'beta'
More linting

See merge request bde/nk20!163
2021-06-14 20:25:40 +00:00
9f0a22d3d1 There is not always an error
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-06-14 22:15:35 +02:00
a5ecdd100c Merge branch '2021' into 'beta'
Update copyright for 2021

See merge request bde/nk20!169
2021-06-14 20:04:15 +00:00
f60691846b Don't block valid payments
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-06-14 21:54:32 +02:00
d5ecb72a71 Update copyright for 2021
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-06-14 21:45:56 +02:00
8cf9dfb9b9 Reduce complexity of the validation of a user, add verbosity in comments
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-06-14 21:43:04 +02:00
c3ab61bd04 Factorize detection of uncomplete payment forms
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-06-14 21:39:29 +02:00
0b4b6dcb3e Merge branch 'fix-mail-source' into 'beta'
Never use default constants. webmaster@localhost is never allowed to send emails.

See merge request bde/nk20!168
2021-06-14 19:25:26 +00:00
0d5f6c0332 Merge branch 'fix-amounts' into 'beta'
Round amounts to the nearest integer rather than take the floor

See merge request bde/nk20!167
2021-06-14 19:24:26 +00:00
7b28938cde Never use default constants. webmaster@localhost is never allowed to send emails.
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-06-07 23:49:46 +02:00
35ffb36fbd Round amounts to the nearest integer rather than take the floor
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-06-07 23:47:07 +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
c4c4e9594f couleur 4.0 2021-05-22 12:34:31 +02:00
4166823d55 couleurs 3.0 2021-05-22 12:29:02 +02:00
dc0f3dbcef Changement de couleur 3.0 2021-05-22 12:19:29 +02:00
4583958f50 Merge branch 'beta' into 'master'
Changement de couleurs

See merge request bde/nk20!165
2021-05-22 09:56:55 +00:00
b3abe9ab18 Changement de couleur 2.0 2021-05-22 11:53:13 +02:00
27f23b48b6 Merge branch 'coulour_vieux' into 'beta'
Bonjour c le changement de couleur

See merge request bde/nk20!164
2021-05-22 09:41:54 +00:00
67e170d4a6 Bonjour c le changement de couleur 2021-05-22 11:30:11 +02:00
8f895dc4d7 note: use native selector rather than Query 2021-05-12 18:03:44 +02:00
1187577728 Do not lint scripts submodule 2021-05-12 17:33:11 +02:00
8a58af3b31 Reformat apps/registration/tokens.py 2021-05-05 19:48:19 +02:00
0c23625147 Remove newline in imports 2021-05-05 19:47:16 +02:00
21219b9c62 Rename join_BDE and join_Kfet to lowercase 2021-05-05 19:46:53 +02:00
5ab8beecef Use _ prefix for ignored loop variable 2021-05-05 19:14:59 +02:00
1ca5133026 BaseException.message is removed in Python 3 2021-05-05 19:12:23 +02:00
93bc6bb245 Do not call setattr with a constant attribute value 2021-05-05 19:12:03 +02:00
952c4383e7 Add flake8-bugbear and lint all apps 2021-05-05 19:09:33 +02:00
15dd2b8f0c PC Kfet can update profile section while renewing memberships
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-29 13:11:00 +02:00
c540b6334c Fix minimum amount for the send_mail_to_negative_balances script
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-27 09:52:34 +02:00
bab394908d Merge branch 'beta' into 'master'
Bugs mineurs, documentation

See merge request bde/nk20!162
2021-04-23 19:32:54 +00:00
0b93968b9e Merge branch 'docs' into 'beta'
More and more documentation

See merge request bde/nk20!156
2021-04-23 19:10:56 +00:00
97375ef6c0 Copy production database to development website
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:57 +02:00
36cfcd533f Documentation on scripts
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:56 +02:00
21dbc53615 Spam negative users if they have less than 0 euro
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:53 +02:00
e6f10ebdac Install production server
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:28 +02:00
47968844ce Install local development server
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:28 +02:00
a435460e29 Personal interface
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:28 +02:00
b7c4360108 How to NK20
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:28 +02:00
8d8c417c50 Fix old markdown remaining in docs 2021-04-23 21:07:28 +02:00
2b189af25b [FAQ] How to create a button?
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-23 21:07:28 +02:00
5a07c8a94f More FAQ
Signed-off-by: ynerant <ynerant@crans.org>
2021-04-23 21:07:27 +02:00
6cc1857eb6 Add FAQ page
Signed-off-by: ynerant <ynerant@crans.org>
2021-04-23 21:07:27 +02:00
601534d610 Add link to the documentatioh on the README
Signed-off-by: ynerant <ynerant@crans.org>
2021-04-23 21:07:27 +02:00
c271593839 Fix markdown typos
Signed-off-by: ynerant <ynerant@crans.org>
2021-04-23 21:07:27 +02:00
f351794aa0 Documentation on documentation
Signed-off-by: ynerant <ynerant@crans.org>
2021-04-23 21:07:27 +02:00
2793fee58c Merge branch 'less-verbosity' into 'beta'
Reduce verbosity of cron scripts

See merge request bde/nk20!161
2021-04-14 13:57:55 +00:00
7a715df121 Treasurers asked to add the list of old members in the monthly reports
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-14 15:48:15 +02:00
9308878054 Adapt verbosity of some scripts
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-14 15:18:49 +02:00
b5ccf5b800 Run cron without verbosity
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-14 15:05:13 +02:00
5e63254439 Merge branch 'fix-reports' into 'beta'
Update last report date only in non-debug mode

See merge request bde/nk20!160
2021-04-14 13:03:04 +00:00
da96506218 Update last report date only in non-debug mode
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-14 14:51:24 +02:00
b4714b896a Merge branch 'fix-reports' into 'beta'
Daily reports were broken

See merge request bde/nk20!159
2021-04-08 17:45:17 +00:00
cdb2647a4d Fix note list when daily reports are sent
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-08 17:33:51 +02:00
cc12e3ec63 Send cron mails to the BDE
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-08 17:31:01 +02:00
be168c5ada Decimal value is serialized as a str value
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-21 10:59:58 +01:00
b46ae6f856 [treasury] Product quantities are finally decimal fields 2021-03-21 10:41:15 +01:00
ec0bcbf015 PC Kfet can see all users
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-21 10:28:50 +01:00
81303b8ef8 Merge branch 'floating-quantities' into 'beta'
[invoices] Product quantities can be floating

See merge request bde/nk20!158
2021-03-13 12:13:50 +00:00
910b98fefc Invoices are in french
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-13 13:04:00 +01:00
5a7a219ba8 [invoices] Quantities can be non-integers
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-13 12:35:28 +01:00
116451603c Merge branch 'cas' into 'beta'
CAS + OAuth2

See merge request bde/nk20!155
2021-03-09 16:39:03 +00:00
b2437ef9b5 Remove additional blank lines
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 17:18:43 +01:00
d8c9618772 OAuth2 documentation
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 17:04:16 +01:00
c825dee95a CAS documentation
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 15:42:36 +01:00
73d27e820b Provide also note information (with balance and picture)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 12:55:19 +01:00
40e1b42078 Fix API path
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 12:54:57 +01:00
72806f0ace Add profile and membership information to OAuth views
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 10:57:35 +01:00
b244e01231 Add simple view to give OAuth information
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 10:41:43 +01:00
76d1784aea Add OAuth2 authentication for Django Rest Framework
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 09:44:25 +01:00
56c5fa4057 We don't need a session to have permissions
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 09:41:27 +01:00
b5ef937a03 Environment file path is absolute
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-09 09:39:57 +01:00
e95a8b6e18 Add normalized name to services
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-03 18:42:51 +01:00
635adf1360 Use cas server to use authentication in other services
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-03-03 18:13:33 +01:00
d5a9bf175f Add script to force delete a user, in case of duplicates
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-02-22 11:54:23 +01:00
b597a6ac5b Fix soge credit deletion when the account is not validated yet
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-02-21 23:05:27 +01:00
a704b92c3d Prez BDE : ajout transaction random + see all buttons 2021-02-20 15:12:08 +01:00
53090b1a21 Fix JS texts
Signed-off-by: ynerant <ynerant@crans.org>
2021-02-14 11:52:37 +01:00
c49af0b83a Merge branch 'beta' into 'master'
Fix memberships

See merge request bde/nk20!147
2021-02-11 20:49:30 +00:00
5a05997d9d Fix date comparison when checking a membership from the parent club
Signed-off-by: ynerant <ynerant@crans.org>
2021-02-11 21:38:44 +01:00
c109cd3ddd Source is not destination
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2021-01-19 15:17:03 +01:00
84304971d7 Add sample translation file for english
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2021-01-19 14:29:12 +01:00
b8b781f9a2 Merge branch 'beta' into 'master'
Beta

Closes #84 et #83

See merge request bde/nk20!146
2021-01-19 12:40:24 +01:00
002128eed2 Merge branch 'fix-js-strings' into 'master'
Fix js strings

Closes #85, #84 et #83

See merge request bde/nk20!144
2021-01-19 12:24:13 +01:00
8d71783c42 Merge branch 'docs' into 'beta'
Docs

See merge request bde/nk20!145
2021-01-19 12:01:45 +01:00
a6f23df7d5 Load the good translation file, fixes #85
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2021-01-19 11:58:19 +01:00
d9c97628e2 Add Clacks Overhead header on each response. Closes #84
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-31 15:40:18 +01:00
893534955d Use the Debian mirror of Crans
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-30 00:04:08 +01:00
dfbf9972c2 By default, automatically change directory to /var/www/note_kfet and source the Python virtual environment in the .bashrc file
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-29 23:27:51 +01:00
b5f3b3ffc1 Use Nginx certbot challenge
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-29 21:44:28 +01:00
3aad4e7398 Agree Let's Encrypt ToS
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-29 21:41:29 +01:00
b4a1b513cc Good bye bde3-virt, welcome bde-note-dev!
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-29 20:05:15 +01:00
c0c64f225c Merge branch 'ansible-fix' into 'beta'
Ansible fix

See merge request bde/nk20!139
2020-12-29 20:01:43 +01:00
9d8f47115c ConsumerViewSet is a bit tricky
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 21:50:48 +01:00
f4156f1b94 Update API links, more detail on filtering
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 21:42:58 +01:00
e60994e065 API Documentation
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 21:06:30 +01:00
801f711994 Merge branch 'beta' into docs 2020-12-23 20:19:40 +01:00
e4568b410f How to authenticate on the API?
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 19:19:46 +01:00
c8f7986d5a Merge branch 'api' into 'beta'
API Filters

See merge request bde/nk20!143
2020-12-23 19:02:59 +01:00
d3a9c442a5 Test the note kfet with Debian Bullseye, Python 3.9 and Django 2.2
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 18:48:09 +01:00
016ab5a9c9 Remove dead code, don't try to cover unnecessary things
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 18:45:05 +01:00
7866ab7ec0 Ordering filters are now properly tested
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 18:25:54 +01:00
f570ff3cd5 Check that permissions are working when accessing to API pages
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 18:21:59 +01:00
6b2638c271 Documentation is located on /doc
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 15:20:30 +01:00
5cb4183e9f Use python Warnings instead of printing messages during tests
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 15:11:33 +01:00
3a20555663 Unit tests for API pages, closes #83
Signed-off-by: Yohann D'ANELLO <yohann.danello@gmail.com>
2020-12-23 14:54:21 +01:00
95be0042e9 Fix transaction API page 2020-12-22 13:28:43 +01:00
48880e7fd3 More API filters for the wei app 2020-12-22 13:11:01 +01:00
e0030771e4 More API filters for the treasury app 2020-12-22 12:53:35 +01:00
d47799e6ee More API filters for the permission app 2020-12-22 12:42:54 +01:00
eae091625a More API filters for the note app 2020-12-22 12:37:21 +01:00
aceb77ffb9 More API filters for the activity app 2020-12-22 03:18:43 +01:00
338c94ed05 More API filters for the member app 2020-12-22 02:58:12 +01:00
290848f904 Non-member people can update their profile everytime 2020-12-02 14:58:14 +01:00
72dca54bbf Wrong path for artifact uploading 2020-11-26 03:13:57 +01:00
117d9da3ba Gitlab compiles the documentation 2020-11-26 02:55:36 +01:00
37efebe85b Ansible builds and deploys the documentation 2020-11-26 02:49:57 +01:00
3af2ec71b6 Extract documentation from the Gitlab wiki and convert it to reStructuredText 2020-11-26 02:29:51 +01:00
0b4a95525b Use RTD theme 2020-11-26 01:07:18 +01:00
af664e481f Initial setup for Sphinx 2020-11-26 01:01:08 +01:00
0171f16311 Merge branch 'beta' into 'master'
Translate some words

See merge request bde/nk20!142
2020-11-21 13:55:26 +01:00
296b94d237 Merge branch 'master' into 'beta'
Translations

See merge request bde/nk20!141
2020-11-21 13:37:24 +01:00
4942553335 Merge branch 'JS_translations' into 'master'
Js translations

Closes #73

See merge request bde/nk20!140
2020-11-21 13:18:32 +01:00
c1efb87180 Fix spanish translations 2020-11-21 12:37:31 +01:00
72eead8595 Add spanish javascript translation 2020-11-21 12:26:31 +01:00
ade7e583e5 Complete spanish translation to 98% 2020-11-21 12:24:55 +01:00
4a8a101822 Translated using Weblate (German)
Currently translated at 100.0% (19 of 19 strings)

Translation: Note Kfet 2020/NK20 - JS
Translate-URL: http://translate.ynerant.fr/projects/nk20/nk20-js/de/
2020-11-16 20:21:46 +00:00
dd2cfa6327 Translated using Weblate (French)
Currently translated at 100.0% (668 of 668 strings)

Translation: Note Kfet 2020/NK20
Translate-URL: http://translate.ynerant.fr/projects/nk20/nk20/fr/
2020-11-16 20:02:32 +00:00
2adf84b7fc Translated using Weblate (German)
Currently translated at 98.0% (655 of 668 strings)

Translation: Note Kfet 2020/NK20
Translate-URL: http://translate.ynerant.fr/projects/nk20/nk20/de/
2020-11-16 20:02:31 +00:00
2f54e64ea2 Merge branch 'JS_translations' into 'beta'
Js translations

See merge request bde/nk20!125
2020-11-16 20:49:46 +01:00
8434c0062c Merge branch 'beta' into JS_translations
# Conflicts:
#	apps/note/static/note/js/consos.js
#	locale/de/LC_MESSAGES/django.po
#	locale/es/LC_MESSAGES/django.po
#	locale/fr/LC_MESSAGES/django.po
2020-11-16 00:59:26 +01:00
6d976f32bf Update django oauth toolkit, fix #73 2020-11-16 00:49:53 +01:00
b9d49d53f2 Export JS translation files as static files 2020-11-16 00:29:27 +01:00
23243e09bb Fix some errors on JS string interpolation 2020-11-15 23:37:36 +01:00
2682e9a610 Add line in README on how to extract localized string in JS files 2020-11-15 23:31:10 +01:00
5635598bbc Extract strings from javascript files and translate them in french 2020-11-15 23:28:41 +01:00
b58a0c43cd Include auto-generated javascript translation file 2020-11-15 22:53:00 +01:00
e1f647bd02 lesser hardcoded 2020-10-30 21:28:25 +01:00
39fd3a2471 set DB_PASSWORD in env file 2020-10-30 20:54:41 +01:00
1072e227b8 don't copy personal config on prod 2020-10-30 17:07:03 +01:00
cbf7e6fe6c run certbot if necessary 2020-10-30 17:01:47 +01:00
950922d041 do not hardcode mail 2020-10-30 17:01:26 +01:00
78fe070cd3 use debian backport only with debian 2020-10-30 16:59:44 +01:00
51d5733578 less hardcoded ansible config 2020-10-30 16:58:49 +01:00
7bd895c1df Grant treasurers to update a note picture 2020-10-26 17:58:30 +01:00
e5e94c52f2 Merge branch 'beta' into 'master'
Permissions PC Kfet

See merge request bde/nk20!138
2020-10-25 22:08:00 +01:00
051591cb7a Don't see user detail in update form 2020-10-25 21:49:16 +01:00
0e7390b669 PC Kfet can see limited user information and clubs. It can create memberships but not see them 2020-10-25 21:38:04 +01:00
fe4363b83d Don't display too much detail when a user has no right to see a profile 2020-10-25 21:29:44 +01:00
6e80016b38 Don't delete object when checking an add permission: this is useless since we rollback to the initial DB state 2020-10-25 21:08:36 +01:00
08e50ffc22 Credit form didn't raise an error when the data didn't validate 2020-10-23 18:19:21 +02:00
9cb65277f3 Merge branch 'beta' into 'master'
Ajustement de permissions

See merge request bde/nk20!137
2020-10-23 17:10:15 +02:00
224a0fdd8c SpecialTransactionProxy are force-saved 2020-10-23 16:55:33 +02:00
6dc7604e90 Alias were duplicated in profile alias list view 2020-10-23 16:48:33 +02:00
cb7f3c9f18 Note account can manage BDE memberships 2020-10-23 16:42:06 +02:00
f910feca9e PC Kfet can create and renew memberships 2020-10-23 13:17:07 +02:00
91f784872c Treasurers can update any roles, not only the BDE-related 2020-10-23 09:50:18 +02:00
b655135a42 Merge branch 'beta' into 'master'
PC Kfet

See merge request bde/nk20!136
2020-10-20 10:43:01 +02:00
58aa4983e3 The note account must be active in order to have access to the Rest Framework API 2020-10-20 10:30:41 +02:00
6cc3cf4174 A migration put the right role in the note account's memberships 2020-10-20 00:28:49 +02:00
2097e67321 Add permissions to PC Kfet 2020-10-20 00:19:49 +02:00
d773303d18 Add possibility to authenticate an account with its IP address 2020-10-19 23:44:56 +02:00
3cabcf40e7 Merge branch 'beta' into 'master'
Display real user name in the Soge credits list/detail

See merge request bde/nk20!135
2020-10-08 10:48:36 +02:00
bf29efda0a Display real user name in the Soge credits list/detail 2020-10-08 10:36:30 +02:00
ceccba0d71 Merge branch 'beta' into 'master'
Highlight users that created a bank account

See merge request bde/nk20!134
2020-10-07 17:55:40 +02:00
3eced33082 Well, everyone doesn't want a secondary bank account 2020-10-07 17:43:28 +02:00
acb3fb4a91 Highlight future users that declared that they opened a bank account 2020-10-07 17:42:46 +02:00
1c5e951c2f Merge branch 'beta' into 'master'
Various fixes

See merge request bde/nk20!133
2020-10-07 12:06:48 +02:00
beb1853aef Forgot to create the aliases for BDE and Kfet in the migration that create the clubs 2020-10-07 11:54:04 +02:00
0078eb8f90 Index page is a redirection 2020-10-07 11:53:42 +02:00
e5e758f9d9 Display banners when a user is no more a BDE or Kfet member 2020-10-07 11:46:43 +02:00
4a78328717 The checkbox to tell that a Sogé account got opened is not mandatory 2020-10-07 11:31:20 +02:00
65a2e8c08c Better index page: non-Kfet members will be redirected to their profile page, the account note (when it will be managed) will see the consumption page 2020-10-07 11:29:52 +02:00
b5fa428bad Non-Kfet members can see their old aliases only, but no one else 2020-10-07 11:22:02 +02:00
fb72385773 Warn users that they have to open they Sogé account 2020-10-07 10:59:37 +02:00
2f68601e8b Delete the soge credit if the user declares that one was opened but in the validation form the checkbox was unchecked 2020-10-07 10:46:33 +02:00
0b1bed8048 Temporary give the right to treasurers to manage membership roles, but need to find a proper solution 2020-10-07 10:43:58 +02:00
8ada0e51f2 The validation filter of the soge credit list was buggy 2020-10-07 10:42:52 +02:00
c3d613947f Pre-registered users can declare that they opened a bank account in the signup form 2020-10-07 10:33:57 +02:00
36b8157372 Fix membership table order 2020-10-07 10:03:43 +02:00
992cfe8e23 Can set a parent club to None 2020-10-07 09:48:21 +02:00
18a8ff1b8a Set credit/debit reason non mandatory 2020-10-07 09:45:09 +02:00
c61bb2e90d When we credit the note of a club directly, fill the last name and the first name information with the club name instead of empty 2020-10-07 09:39:40 +02:00
4b12e3ed08 Display only the most recent membership 2020-10-07 09:29:41 +02:00
af07ed9807 Merge branch 'erdnaxe-master-patch-99095' into 'beta'
Erdnaxe master patch 99095

See merge request bde/nk20!132
2020-10-05 16:37:14 +02:00
bbe53b3b63 Update README.md 2020-10-05 16:25:06 +02:00
536f0ec226 Merge branch 'beta' into 'master'
Ensure the integrity of all note balances in multiple transactions

See merge request bde/nk20!131
2020-10-04 22:12:12 +02:00
541ed59f40 When a membership is created, redirect to the user profile page rather than club detail 2020-10-04 21:08:35 +02:00
e172b4f4bb When a membership is renewed, set the same roles as the previous membership 2020-10-04 20:54:03 +02:00
d666179037 Display Renew membership button 15 days more 2020-10-04 20:50:10 +02:00
f22e92132c Select for update transaction notes, and not only the transaction 2020-10-04 20:47:15 +02:00
ca7ad05746 Use a signal to prevent a user that the note balance is negative 2020-10-04 20:26:43 +02:00
f55ca2f725 Merge branch 'beta' into 'master'
remove the display limit for pre-registred users.

See merge request bde/nk20!130
2020-10-03 18:07:39 +02:00
d4e4ed580f remove the display limit for pre-registred users. 2020-10-03 17:53:38 +02:00
8756751344 Merge branch 'beta' into 'master'
Fix some membership issues

See merge request bde/nk20!129
2020-10-01 15:00:36 +02:00
fd83fe19bf Fix some membership date control 2020-10-01 09:17:02 +02:00
a00d95608b Add permission to treasurers to create a club, fix the permission check to renew a membership 2020-09-23 21:36:04 +02:00
3303edd01f Merge branch 'beta' into 'master'
OAuth2, no side effect permissions

See merge request bde/nk20!128
2020-09-23 18:44:40 +02:00
e48ef92137 Revert commit that broke beta branch 2020-09-23 18:32:09 +02:00
919d0b7e85 Merge branch 'ics_cache' into 'beta'
Ics cache

See merge request bde/nk20!127
2020-09-21 15:46:34 +02:00
439bf35b62 APT python memcache is PyPi memcached 2020-09-21 15:25:07 +02:00
74b26335d1 Cache ICS calendar 2020-09-21 15:13:59 +02:00
3d733ed6af Use memcached cache 2020-09-21 15:13:43 +02:00
d54ab94ceb Merge branch 'oauth' into 'beta'
Oauth

See merge request bde/nk20!126
2020-09-21 12:53:03 +02:00
4f188ca3e5 Admin is autodiscovering partially 2020-09-21 12:34:34 +02:00
72bac75fbd Add Django OAuth toolkit admin 2020-09-21 12:15:40 +02:00
6d54aae614 Fix django-oauth-toolkit version 2020-09-21 11:15:00 +02:00
8052152ea5 Add OAuth2 endpoints 2020-09-21 11:03:07 +02:00
70448db8e5 Remove Django CAS server and add oauth toolkit 2020-09-21 10:31:42 +02:00
ac2d1e8111 Merge branch 'no_side_effect_permission_check' into 'beta'
No side effect permission check

See merge request bde/nk20!124
2020-09-20 11:24:46 +02:00
3ba61385a3 Debit is not credit 2020-09-20 11:12:44 +02:00
7353348d7a Rollback transaction when checking an add permission (experimental) 2020-09-20 09:07:51 +02:00
f63e2e088e Don't log when the permission to lock a note is checked 2020-09-20 08:56:42 +02:00
420a24ebac enable JavaScriptCatalog view 2020-09-19 22:42:35 +02:00
d566def706 Try to translate js, not working... 2020-09-19 22:03:45 +02:00
eaf6769e8b Treasurers can make transactions with people that are no longer a member 2020-09-19 16:33:52 +02:00
a61ec81cff note.crans.org is the default domain 2020-09-19 16:03:32 +02:00
60f2a73cc5 Don't check if the user is a member of the parent club if there is no parent club 2020-09-18 13:35:55 +02:00
bcd96b2ed8 The BDE membership and the club membership must now be in two parts 2020-09-18 12:35:36 +02:00
5c702187e5 Merge branch 'beta' into 'master'
Corrections diverses

See merge request bde/nk20!123
2020-09-14 10:16:13 +02:00
905d65371f The user validation form was ugly 2020-09-14 09:56:15 +02:00
180cd3e1ec Fix registration permissions and procedure 2020-09-14 09:49:30 +02:00
73ca65aa91 Merge branch 'atomicity' into 'beta'
Atomicité

See merge request bde/nk20!122
2020-09-14 09:38:54 +02:00
5ed0560953 Fix linting 2020-09-14 09:09:20 +02:00
dbc6fbbf71 Fix the validation clicker issue, now the note is safe 2020-09-14 09:05:35 +02:00
872fd8f86d Don't cache permissions in debug mode, that's very slow 2020-09-14 08:58:12 +02:00
f89234b69a JS was broken, please close your HTML tags 2020-09-13 23:18:00 +02:00
36a980555b Revert "Make the nk20 usable for pirates"
This reverts commit 0f53ac45f7.
2020-09-13 20:42:44 +02:00
826cd4d87f Revert "Use underscore in locales"
This reverts commit 2270a0aa82.
2020-09-13 20:42:34 +02:00
e8005a6c58 Update Django locale selector 2020-09-13 20:17:59 +02:00
2270a0aa82 Use underscore in locales 2020-09-13 20:10:26 +02:00
0f53ac45f7 Make the nk20 usable for pirates 2020-09-13 20:05:06 +02:00
670556c59e Merge branch 'traduction' into 'beta'
End of Spanish translation

See merge request bde/nk20!121
2020-09-13 18:55:56 +02:00
5b02ba48e0 Some OTL, but so much remain 2020-09-13 12:40:10 +02:00
f3f18bc25e Merge branch 'beta' into traduction 2020-09-13 12:17:25 +02:00
03124e124c Translation : typo 2020-09-13 11:52:43 +02:00
6308964e93 Translation : French OTL 2020-09-13 11:52:13 +02:00
ed79097288 Spanish translation : ~end 2020-09-13 11:51:29 +02:00
d7eaef8cee Spanish translation : 88%, ¡ quedan 77 para mañana ! 2020-09-12 22:54:41 +02:00
01d405e54b Spanish translation : 70%, a comer! 2020-09-12 20:18:11 +02:00
80e3cba4c6 BDE Treasurers can see the remittance interface 2020-09-12 18:40:14 +02:00
f190053e84 Display the right amount in soge credit detail 2020-09-12 18:36:05 +02:00
218960adb5 Spanish translation : 57%, not always 57 2020-09-12 16:34:24 +02:00
88a1eae631 Spanish translation : 42%, always 42 2020-09-12 11:59:59 +02:00
2a2ecb2acc Activate es locale 2020-09-12 09:17:15 +02:00
f5486bdb63 Merge branch 'traduction' into 'beta'
Traduction

See merge request bde/nk20!120
2020-09-12 09:19:04 +02:00
9b090a145c All transactions are now atomic 2020-09-11 22:52:16 +02:00
860c7b50e5 Filter a consumer by its note id 2020-09-10 14:42:52 +02:00
afdc75c0bd Access to consumer object wa buggy 2020-09-10 14:41:09 +02:00
c6603e8aa7 Add more filters in the API 2020-09-10 14:37:11 +02:00
72cc1638e6 Authenticate correctly users that connect with an authorization token 2020-09-10 09:31:27 +02:00
6a0dc4cb10 Users can see every API page since querysets are filtered and modifications are protected 2020-09-09 22:27:07 +02:00
0f1f3b9560 Do not serve static files outside of debug server 2020-09-09 17:14:03 +02:00
c720e5483e Move transfer.js where it belongs 2020-09-09 16:45:15 +02:00
0fd3e9db78 Move consos.js where it belongs 2020-09-09 16:42:45 +02:00
c34296c923 Merge branch 'fixed_width_image' into 'beta'
Fix profile picture width

See merge request bde/nk20!119
2020-09-09 15:18:50 +02:00
ce4c22a4a1 Smaller text and larger padding on note label 2020-09-09 15:03:34 +02:00
3e0f665ef8 Resync es translation 2020-09-09 14:32:01 +02:00
be8751c815 Merge branch 'beta' into traduction 2020-09-09 14:28:19 +02:00
8225445c3e Update translations 2020-09-09 14:10:07 +02:00
f333e6a875 Fix profile picture width 2020-09-09 14:03:49 +02:00
e5835b46a5 Backups are sent to Zamok 2020-09-08 13:31:22 +02:00
fe937405a6 Merge remote-tracking branch 'origin/beta' into beta 2020-09-08 10:11:44 +02:00
0741c8ad2b Refactor the script to extract the mails that are registered to an events mailing list 2020-09-08 10:11:33 +02:00
3191dba31f Merge branch 'beta' into 'master'
Fix permissions to let treasurers to make some initial registrations

See merge request bde/nk20!118
2020-09-07 23:52:12 +02:00
428de69d93 Fix permissions to let treasurers to make some initial registrations 2020-09-07 23:36:50 +02:00
0888afe439 I am hungry, so I ham hungry 2020-09-04 20:38:57 +02:00
3111c30e56 Add spanish translation 2020-09-04 18:24:49 +02:00
275 changed files with 20510 additions and 2710 deletions

View File

@ -10,7 +10,6 @@ DJANGO_SECRET_KEY=CHANGE_ME
DJANGO_SETTINGS_MODULE=note_kfet.settings DJANGO_SETTINGS_MODULE=note_kfet.settings
CONTACT_EMAIL=tresorerie.bde@localhost CONTACT_EMAIL=tresorerie.bde@localhost
NOTE_URL=localhost NOTE_URL=localhost
DOMAIN=localhost
# Config for mails. Only used in production # Config for mails. Only used in production
NOTE_MAIL=notekfet@localhost NOTE_MAIL=notekfet@localhost

6
.gitignore vendored
View File

@ -47,3 +47,9 @@ backups/
env/ env/
venv/ venv/
db.sqlite3 db.sqlite3
shell.nix
# ansibles customs host
ansible/host_vars/*.yaml
!ansible/host_vars/bde*
ansible/hosts

View File

@ -1,6 +1,7 @@
stages: stages:
- test - test
- quality-assurance - quality-assurance
- docs
# Also fetch submodules # Also fetch submodules
variables: variables:
@ -16,8 +17,8 @@ py37-django22:
apt-get install --no-install-recommends -t buster-backports -y apt-get install --no-install-recommends -t buster-backports -y
python3-django python3-django-crispy-forms python3-django python3-django-crispy-forms
python3-django-extensions python3-django-filters python3-django-polymorphic python3-django-extensions python3-django-filters python3-django-polymorphic
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py37-django22 script: tox -e py37-django22
@ -33,11 +34,26 @@ py38-django22:
apt-get install --no-install-recommends -y apt-get install --no-install-recommends -y
python3-django python3-django-crispy-forms python3-django python3-django-crispy-forms
python3-django-extensions python3-django-filters python3-django-polymorphic python3-django-extensions python3-django-filters python3-django-polymorphic
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py38-django22 script: tox -e py38-django22
# Debian Bullseye
py39-django22:
stage: test
image: debian:bullseye
before_script:
- >
apt-get update &&
apt-get install --no-install-recommends -y
python3-django python3-django-crispy-forms
python3-django-extensions python3-django-filters python3-django-polymorphic
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py39-django22
linters: linters:
stage: quality-assurance stage: quality-assurance
image: debian:buster-backports image: debian:buster-backports
@ -47,3 +63,17 @@ linters:
# Be nice to new contributors, but please use `tox` # Be nice to new contributors, but please use `tox`
allow_failure: true allow_failure: true
# Compile documentation
documentation:
stage: docs
image: sphinxdoc/sphinx
before_script:
- pip install sphinx-rtd-theme
- cd docs
script:
- make dirhtml
artifacts:
paths:
- docs/_build
expire_in: 1 day

View File

@ -8,8 +8,8 @@ RUN apt-get update && \
apt-get install --no-install-recommends -t buster-backports -y \ apt-get install --no-install-recommends -t buster-backports -y \
python3-django python3-django-crispy-forms \ python3-django python3-django-crispy-forms \
python3-django-extensions python3-django-filters python3-django-polymorphic \ python3-django-extensions python3-django-filters python3-django-polymorphic \
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil \ python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil \
python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \ python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache ipython3 \
python3-bs4 python3-setuptools \ python3-bs4 python3-setuptools \
uwsgi uwsgi-plugin-python3 \ uwsgi uwsgi-plugin-python3 \
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome && \ texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome && \

View File

@ -1,8 +1,8 @@
# NoteKfet 2020 # NoteKfet 2020
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt) [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt)
[![pipeline status](https://gitlab.crans.org/bde/nk20/badges/master/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/master) [![pipeline status](https://gitlab.crans.org/bde/nk20/badges/main/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master) [![coverage report](https://gitlab.crans.org/bde/nk20/badges/main/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
## Table des matières ## Table des matières
@ -69,13 +69,31 @@ accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
de la note sur un téléphone ! de la note sur un téléphone !
## Installation d'une instance de production ## Installation d'une instance de production
Pour déployer facilement la note il est possible d'utiliser le playbook Ansible (sinon vous pouvez toujours le faire a la main, voir plus bas).
### Avec ansible
Il vous faudra un serveur sous debian ou ubuntu connecté à internet et que vous souhaiterez accéder à cette instance de la note sur `note.nomdedomaine.tld`.
0. Installer Ansible sur votre machine personnelle.
0. (bis) cloner le dépot sur votre machine personelle.
1. Copier le fichier `ansible/host_example`
``` bash
$ cp ansible/hosts_example ansible/hosts
```
et ajouter sous [dev] et/ou [prod] les serveurs sur lesquels vous souhaitez installer la note.
2. Créer un fichier `ansible/host_vars/<note.nomdedomaine.tld.yaml>` sur le modèle des fichiers existants dans `ansible/hosts` et compléter les variables nécessaires.
3. lancer `ansible/base.yaml -l <nomdedomaine.tld.yaml>`
4. Aller vous faire un café, ca peux durer un moment.
### Installation manuelle
**En production on souhaite absolument utiliser les modules Python packagées dans le gestionnaire de paquet.** **En production on souhaite absolument utiliser les modules Python packagées dans le gestionnaire de paquet.**
Cela permet de mettre à jour facilement les dépendances critiques telles que Django. Cela permet de mettre à jour facilement les dépendances critiques telles que Django.
L'installation d'une instance de production néccessite **une installation de Debian Buster ou d'Ubuntu 20.04**. L'installation d'une instance de production néccessite **une installation de Debian Buster ou d'Ubuntu 20.04**.
Pour aller vite vous pouvez lancer le Playbook Ansible fournit dans ce dépôt en l'adaptant.
Sinon vous pouvez suivre les étapes décrites ci-dessous. Sinon vous pouvez suivre les étapes décrites ci-dessous.
0. Sous Debian Buster, **activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports. 0. Sous Debian Buster, **activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports.
@ -93,10 +111,10 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
$ sudo apt install --no-install-recommends -t buster-backports -y \ $ sudo apt install --no-install-recommends -t buster-backports -y \
python3-django python3-django-crispy-forms \ python3-django python3-django-crispy-forms \
python3-django-extensions python3-django-filters python3-django-polymorphic \ python3-django-extensions python3-django-filters python3-django-polymorphic \
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil \ python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil \
python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \ python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache ipython3 \
python3-bs4 python3-setuptools \ python3-bs4 python3-setuptools python3-docutils \
uwsgi uwsgi-plugin-python3 \ memcached uwsgi uwsgi-plugin-python3 \
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome \ texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome \
nginx python3-venv git acl nginx python3-venv git acl
``` ```
@ -261,20 +279,25 @@ Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.cr
La documentation des classes et fonctions est directement dans le code et est explorable à partir de la partie documentation de l'interface d'administration de Django. La documentation des classes et fonctions est directement dans le code et est explorable à partir de la partie documentation de l'interface d'administration de Django.
**Commentez votre code !** **Commentez votre code !**
La documentation plus haut niveau sur le développement est disponible sur [le Wiki associé au dépôt Git](https://gitlab.crans.org/bde/nk20/-/wikis/home). La documentation plus haut niveau sur le développement et sur l'utilisation
est disponible sur <https://note.crans.org/doc> et également dans le dossier `docs`.
## FAQ ## FAQ
### Regénérer les fichiers de traduction ### Regénérer les fichiers de traduction
Pour regénérer les traductions vous pouvez vous placer à la racine du projet et lancer le script `makemessages`. Il faut penser à ignorer les dossiers ne contenant pas notre code, dont le virtualenv. Pour regénérer les traductions vous pouvez vous placer à la racine du projet et lancer le script `makemessages`.
Il faut penser à ignorer les dossiers ne contenant pas notre code, dont le virtualenv.
De plus, il faut aussi extraire les variables des fichiers JavaScript.
```bash ```bash
django-admin makemessages -i env python3 manage.py makemessages -i env
python3 manage.py makemessages -i env -e js -d djangojs
``` ```
Une fois les fichiers édités, vous pouvez compiler les nouvelles traductions avec Une fois les fichiers édités, vous pouvez compiler les nouvelles traductions avec
```bash ```bash
django-admin compilemessages python3 manage.py compilemessages
python3 manage.py compilejsmessages
``` ```

View File

@ -7,7 +7,7 @@
prompt: "Password of the database (leave it blank to skip database init)" prompt: "Password of the database (leave it blank to skip database init)"
private: yes private: yes
vars: vars:
mirror: deb.debian.org mirror: eclats.crans.org
roles: roles:
- 1-apt-basic - 1-apt-basic
- 2-nk20 - 2-nk20
@ -16,3 +16,4 @@
- 5-nginx - 5-nginx
- 6-psql - 6-psql
- 7-postinstall - 7-postinstall
- 8-docs

View File

@ -1,5 +0,0 @@
---
note:
server_name: note-beta.crans.org
git_branch: beta
cron_enabled: false

View File

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

View File

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

View File

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

View File

@ -1,13 +1,15 @@
--- ---
- name: Add buster-backports to apt sources - name: Add buster-backports to apt sources if needed
apt_repository: apt_repository:
repo: deb http://{{ mirror }}/debian buster-backports main repo: deb http://{{ mirror }}/debian buster-backports main
state: present state: present
when:
- ansible_distribution == "Debian"
- ansible_distribution_major_version | int == 10
- name: Install note_kfet APT dependencies - name: Install note_kfet APT dependencies
apt: apt:
update_cache: true update_cache: true
default_release: buster-backports
install_recommends: false install_recommends: false
name: name:
# Common tools # Common tools
@ -23,13 +25,14 @@
- python3-babel - python3-babel
- python3-bs4 - python3-bs4
- python3-django - python3-django
- python3-django-cas-server
- python3-django-crispy-forms - python3-django-crispy-forms
- python3-django-extensions - python3-django-extensions
- python3-django-filters - python3-django-filters
- python3-django-oauth-toolkit
- python3-django-polymorphic - python3-django-polymorphic
- python3-djangorestframework - python3-djangorestframework
- python3-lockfile - python3-lockfile
- python3-memcache
- python3-phonenumbers - python3-phonenumbers
- python3-pil - python3-pil
- python3-pip - python3-pip
@ -40,6 +43,9 @@
# LaTeX (PDF generation) # LaTeX (PDF generation)
- texlive-xetex - texlive-xetex
# Cache server
- memcached
# WSGI server # WSGI server
- uwsgi - uwsgi
- uwsgi-plugin-python3 - uwsgi-plugin-python3

View File

@ -16,7 +16,7 @@
- name: Use default env vars (should be updated!) - name: Use default env vars (should be updated!)
template: template:
src: "env_example" src: "env.j2"
dest: "/var/www/note_kfet/.env" dest: "/var/www/note_kfet/.env"
mode: 0644 mode: 0644
force: false force: false
@ -36,3 +36,13 @@
dest: /etc/cron.d/note dest: /etc/cron.d/note
owner: root owner: root
group: root group: root
- name: Set default directory to /var/www/note_kfet
lineinfile:
path: /etc/skel/.bashrc
line: 'cd /var/www/note_kfet'
- name: Automatically source Python virtual environment
lineinfile:
path: /etc/skel/.bashrc
line: 'source /var/www/note_kfet/env/bin/activate'

View File

@ -0,0 +1,23 @@
DJANGO_APP_STAGE=prod
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
DJANGO_DEV_STORE_METHOD=sqlite
DJANGO_DB_HOST=localhost
DJANGO_DB_NAME=note_db
DJANGO_DB_USER=note
DJANGO_DB_PASSWORD={{ DB_PASSWORD }}
DJANGO_DB_PORT=
DJANGO_SECRET_KEY=CHANGE_ME
DJANGO_SETTINGS_MODULE=note_kfet.settings
CONTACT_EMAIL=tresorerie.bde@localhost
NOTE_URL= {{note.server_name}}
# Config for mails. Only used in production
NOTE_MAIL=notekfet@localhost
EMAIL_HOST=smtp.localhost
EMAIL_PORT=25
EMAIL_USER=notekfet@localhost
EMAIL_PASSWORD=CHANGE_ME
# Wiki configuration
WIKI_USER=NoteKfet2020
WIKI_PASSWORD=

View File

@ -9,6 +9,11 @@
retries: 3 retries: 3
until: pkg_result is succeeded until: pkg_result is succeeded
- name: Check if certificate already exists.
stat:
path: /etc/letsencrypt/live/{{note.server_name}}/cert.pem
register: letsencrypt_cert
- name: Create /etc/letsencrypt/conf.d - name: Create /etc/letsencrypt/conf.d
file: file:
path: /etc/letsencrypt/conf.d path: /etc/letsencrypt/conf.d
@ -19,3 +24,17 @@
src: "letsencrypt/conf.d/nk20.ini.j2" src: "letsencrypt/conf.d/nk20.ini.j2"
dest: "/etc/letsencrypt/conf.d/nk20.ini" dest: "/etc/letsencrypt/conf.d/nk20.ini"
mode: 0644 mode: 0644
- name: Stop services to allow certbot to generate a cert.
service:
name: nginx
state: stopped
- name: Generate new certificate if one doesn't exist.
shell: "certbot certonly --non-interactive --agree-tos --config /etc/letsencrypt/conf.d/nk20.ini -d {{note.server_name}}"
when: letsencrypt_cert.stat.exists == False
- name: Restart services to allow certbot to generate a cert.
service:
name: nginx
state: started

View File

@ -10,7 +10,7 @@ rsa-key-size = 4096
# server = https://acme-staging.api.letsencrypt.org/directory # server = https://acme-staging.api.letsencrypt.org/directory
# Uncomment and update to register with the specified e-mail address # Uncomment and update to register with the specified e-mail address
email = notekfet2020@lists.crans.org email = {{ note.email }}
# Uncomment to use a text interface instead of ncurses # Uncomment to use a text interface instead of ncurses
text = True text = True

View File

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

View File

@ -11,14 +11,14 @@
until: pkg_result is succeeded until: pkg_result is succeeded
- name: Create role note - name: Create role note
when: "DB_PASSWORD|bool" # If the password is not defined, skip the installation when: DB_PASSWORD|length > 0 # If the password is not defined, skip the installation
postgresql_user: postgresql_user:
name: note name: note
password: "{{ DB_PASSWORD }}" password: "{{ DB_PASSWORD }}"
become_user: postgres become_user: postgres
- name: Create NK20 database - name: Create NK20 database
when: "DB_PASSWORD|bool" when: DB_PASSWORD|length >0
postgresql_db: postgresql_db:
name: note_db name: note_db
owner: note owner: note

View File

@ -1,4 +1,10 @@
--- ---
- name: Collect static files
command: /var/www/note_kfet/env/bin/python manage.py collectstatic --noinput
args:
chdir: /var/www/note_kfet
become_user: www-data
- name: Migrate Django database - name: Migrate Django database
command: /var/www/note_kfet/env/bin/python manage.py migrate command: /var/www/note_kfet/env/bin/python manage.py migrate
args: args:
@ -11,14 +17,14 @@
chdir: /var/www/note_kfet chdir: /var/www/note_kfet
become_user: www-data become_user: www-data
- name: Compile JavaScript messages
command: /var/www/note_kfet/env/bin/python manage.py compilejsmessages
args:
chdir: /var/www/note_kfet
become_user: www-data
- name: Install initial fixtures - name: Install initial fixtures
command: /var/www/note_kfet/env/bin/python manage.py loaddata initial command: /var/www/note_kfet/env/bin/python manage.py loaddata initial
args: args:
chdir: /var/www/note_kfet chdir: /var/www/note_kfet
become_user: postgres become_user: postgres
- name: Collect static files
command: /var/www/note_kfet/env/bin/python manage.py collectstatic --noinput
args:
chdir: /var/www/note_kfet
become_user: www-data

View File

@ -0,0 +1,20 @@
---
- name: Install Sphinx and RTD theme
pip:
requirements: /var/www/note_kfet/docs/requirements.txt
virtualenv: /var/www/note_kfet/env
virtualenv_command: /usr/bin/python3 -m venv
virtualenv_site_packages: true
become_user: www-data
- name: Create documentation directory with good permissions
file:
path: /var/www/documentation
state: directory
owner: www-data
group: www-data
mode: u=rwx,g=rwxs,o=rx
- name: Build HTML documentation
command: /var/www/note_kfet/env/bin/sphinx-build -b dirhtml /var/www/note_kfet/docs/ /var/www/documentation/
become_user: www-data

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'activity.apps.ActivityConfig' default_app_config = 'activity.apps.ActivityConfig'

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin from django.contrib import admin

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers from rest_framework import serializers

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
@ -15,10 +15,10 @@ class ActivityTypeViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/type/ then render it on /api/activity/type/
""" """
queryset = ActivityType.objects.all() queryset = ActivityType.objects.order_by('id')
serializer_class = ActivityTypeSerializer serializer_class = ActivityTypeSerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
filterset_fields = ['name', 'can_invite', ] filterset_fields = ['name', 'manage_entries', 'can_invite', 'guest_entry_fee', ]
class ActivityViewSet(ReadProtectedModelViewSet): class ActivityViewSet(ReadProtectedModelViewSet):
@ -27,10 +27,16 @@ class ActivityViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/activity/ then render it on /api/activity/activity/
""" """
queryset = Activity.objects.all() queryset = Activity.objects.order_by('id')
serializer_class = ActivitySerializer serializer_class = ActivitySerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', 'description', 'activity_type', ] filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
'date_start', 'date_end', 'valid', 'open', ]
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
'$creater__email', '$creater__note__alias__name', '$creater__note__alias__normalized_name',
'$organizer__name', '$organizer__email', '$organizer__note__alias__name',
'$organizer__note__alias__normalized_name', '$attendees_club__name', '$attendees_club__email',
'$attendees_club__note__alias__name', '$attendees_club__note__alias__normalized_name', ]
class GuestViewSet(ReadProtectedModelViewSet): class GuestViewSet(ReadProtectedModelViewSet):
@ -39,10 +45,13 @@ class GuestViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/guest/ then render it on /api/activity/guest/
""" """
queryset = Guest.objects.all() queryset = Guest.objects.order_by('id')
serializer_class = GuestSerializer serializer_class = GuestSerializer
filter_backends = [SearchFilter] filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ] filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
'inviter__alias__normalized_name', ]
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
'$inviter__alias__normalized_name', ]
class EntryViewSet(ReadProtectedModelViewSet): class EntryViewSet(ReadProtectedModelViewSet):
@ -51,7 +60,9 @@ class EntryViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Entry` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Entry` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/entry/ then render it on /api/activity/entry/
""" """
queryset = Entry.objects.all() queryset = Entry.objects.order_by('id')
serializer_class = EntrySerializer serializer_class = EntrySerializer
filter_backends = [SearchFilter] filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ] filterset_fields = ['activity', 'time', 'note', 'guest', ]
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
'$guest__last_name', '$guest__first_name', ]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -6,7 +6,7 @@
"name": "Pot", "name": "Pot",
"manage_entries": true, "manage_entries": true,
"can_invite": true, "can_invite": true,
"guest_entry_fee": 500 "guest_entry_fee": 1000
} }
}, },
{ {
@ -28,5 +28,25 @@
"can_invite": false, "can_invite": false,
"guest_entry_fee": 0 "guest_entry_fee": 0
} }
},
{
"model": "activity.activitytype",
"pk": 5,
"fields": {
"name": "Soir\u00e9e avec entrées",
"manage_entries": true,
"can_invite": false,
"guest_entry_fee": 0
}
},
{
"model": "activity.activitytype",
"pk": 7,
"fields": {
"name": "Soir\u00e9e avec invitations",
"manage_entries": true,
"can_invite": true,
"guest_entry_fee": 0
}
} }
] ]

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os import os
@ -7,7 +7,7 @@ from threading import Thread
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -123,6 +123,7 @@ class Activity(models.Model):
verbose_name=_('open'), verbose_name=_('open'),
) )
@transaction.atomic
def save(self, *args, **kwargs): 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, ...)
@ -194,8 +195,8 @@ class Entry(models.Model):
else _("Entry for {note} to the activity {activity}").format( else _("Entry for {note} to the activity {activity}").format(
guest=str(self.guest), note=str(self.note), activity=str(self.activity)) guest=str(self.guest), note=str(self.note), activity=str(self.activity))
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest) qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
if qs.exists(): if qs.exists():
raise ValidationError(_("Already entered on ") + _("{:%Y-%m-%d %H:%M:%S}").format(qs.get().time, )) raise ValidationError(_("Already entered on ") + _("{:%Y-%m-%d %H:%M:%S}").format(qs.get().time, ))
@ -260,6 +261,7 @@ class Guest(models.Model):
except AttributeError: except AttributeError:
return False return False
@transaction.atomic
def save(self, force_insert=False, force_update=False, using=None, update_fields=None): def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
one_year = timedelta(days=365) one_year = timedelta(days=365)

View File

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

View File

@ -30,7 +30,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
headers: {"X-CSRFTOKEN": CSRF_TOKEN} headers: {"X-CSRFTOKEN": CSRF_TOKEN}
}) })
.done(function() { .done(function() {
addMsg('Invité supprimé','success'); addMsg('{% trans "Guest deleted" %}', 'success');
$("#guests_table").load(location.pathname + " #guests_table"); $("#guests_table").load(location.pathname + " #guests_table");
}) })
.fail(function(xhr, textStatus, error) { .fail(function(xhr, textStatus, error) {

View File

@ -63,7 +63,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
refreshBalance(); refreshBalance();
} }
alias_obj.keyup(reloadTable); alias_obj.keyup(function(event) {
let code = event.originalEvent.keyCode
if (65 <= code <= 122 || code === 13) {
debounce(reloadTable)()
}
});
$(document).ready(init); $(document).ready(init);
@ -86,10 +91,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
}).done(function () { }).done(function () {
if (target.hasClass("table-info")) if (target.hasClass("table-info"))
addMsg( addMsg(
"Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.", "{% trans "Entry done, but caution: the user is not a Kfet member." %}",
"warning", 10000); "warning", 10000);
else else
addMsg("Entrée effectuée !", "success", 4000); addMsg("Entry made!", "success", 4000);
reloadTable(true); reloadTable(true);
}).fail(function (xhr) { }).fail(function (xhr) {
errMsg(xhr.responseJSON, 4000); errMsg(xhr.responseJSON, 4000);
@ -121,10 +126,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
}).done(function () { }).done(function () {
if (target.hasClass("table-info")) if (target.hasClass("table-info"))
addMsg( addMsg(
"Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.", "{% trans "Entry done, but caution: the user is not a Kfet member." %}",
"warning", 10000); "warning", 10000);
else else
addMsg("Entrée effectuée !", "success", 4000); addMsg("{% trans "Entry done!" %}", "success", 4000);
reloadTable(true); reloadTable(true);
}).fail(function (xhr) { }).fail(function (xhr) {
errMsg(xhr.responseJSON, 4000); errMsg(xhr.responseJSON, 4000);

View File

@ -1,15 +1,18 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta from datetime import timedelta
from api.tests import TestAPI
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from activity.models import Activity, ActivityType, Guest, Entry
from member.models import Club from member.models import Club
from ..api.views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
from ..models import Activity, ActivityType, Guest, Entry
class TestActivities(TestCase): class TestActivities(TestCase):
""" """
@ -173,3 +176,58 @@ class TestActivities(TestCase):
""" """
response = self.client.get(reverse("activity:calendar_ics")) response = self.client.get(reverse("activity:calendar_ics"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class TestActivityAPI(TestAPI):
def setUp(self) -> None:
super().setUp()
self.activity = Activity.objects.create(
name="Activity",
description="This is a test activity\non two very very long lines\nbecause this is very important.",
location="Earth",
activity_type=ActivityType.objects.get(name="Pot"),
creater=self.user,
organizer=Club.objects.get(name="Kfet"),
attendees_club=Club.objects.get(name="Kfet"),
date_start=timezone.now(),
date_end=timezone.now() + timedelta(days=2),
valid=True,
)
self.guest = Guest.objects.create(
activity=self.activity,
inviter=self.user.note,
last_name="GUEST",
first_name="Guest",
)
self.entry = Entry.objects.create(
activity=self.activity,
note=self.user.note,
guest=self.guest,
)
def test_activity_api(self):
"""
Load Activity API page and test all filters and permissions
"""
self.check_viewset(ActivityViewSet, "/api/activity/activity/")
def test_activity_type_api(self):
"""
Load ActivityType API page and test all filters and permissions
"""
self.check_viewset(ActivityTypeViewSet, "/api/activity/type/")
def test_entry_api(self):
"""
Load Entry API page and test all filters and permissions
"""
self.check_viewset(EntryViewSet, "/api/activity/entry/")
def test_guest_api(self):
"""
Load Guest API page and test all filters and permissions
"""
self.check_viewset(GuestViewSet, "/api/activity/guest/")

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path from django.urls import path

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from hashlib import md5 from hashlib import md5
@ -7,12 +7,15 @@ from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django.views.decorators.cache import cache_page
from django.views.generic import DetailView, TemplateView, UpdateView from django.views.generic import DetailView, TemplateView, UpdateView
from django_tables2.views import SingleTableView from django_tables2.views import SingleTableView
from note.models import Alias, NoteSpecial, NoteUser from note.models import Alias, NoteSpecial, NoteUser
@ -44,6 +47,7 @@ class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
date_end=timezone.now(), date_end=timezone.now(),
) )
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
form.instance.creater = self.request.user form.instance.creater = self.request.user
return super().form_valid(form) return super().form_valid(form)
@ -62,21 +66,19 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
ordering = ('-date_start',) ordering = ('-date_start',)
extra_context = {"title": _("Activities")} extra_context = {"title": _("Activities")}
def get_queryset(self): def get_queryset(self, **kwargs):
return super().get_queryset().distinct() return super().get_queryset(**kwargs).distinct()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
context['upcoming'] = ActivityTable( context['upcoming'] = ActivityTable(
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")),
prefix='upcoming-', prefix='upcoming-',
) )
started_activities = Activity.objects\ started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.filter(open=True, valid=True).all()
context["started_activities"] = started_activities context["started_activities"] = started_activities
return context return context
@ -94,7 +96,7 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data() context = super().get_context_data()
table = GuestTable(data=Guest.objects.filter(activity=self.object) table = GuestTable(data=Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) .filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
context["guests"] = table context["guests"] = table
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
@ -140,14 +142,15 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.get(pk=self.kwargs["pk"]) .filter(pk=self.kwargs["pk"]).first()
form.fields["inviter"].initial = self.request.user.note form.fields["inviter"].initial = self.request.user.note
return form return form
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
form.instance.activity = Activity.objects\ form.instance.activity = Activity.objects\
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"])
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
@ -165,10 +168,13 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
it is closed or doesn't manage entries. it is closed or doesn't manage entries.
""" """
if not self.request.user.is_authenticated:
return self.handle_no_permission()
activity = Activity.objects.get(pk=self.kwargs["pk"]) activity = Activity.objects.get(pk=self.kwargs["pk"])
sample_entry = Entry(activity=activity, note=self.request.user.note) sample_entry = Entry(activity=activity, note=self.request.user.note)
if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): if not PermissionBackend.check_perm(self.request, "activity.add_entry", sample_entry):
raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
if not activity.activity_type.manage_entries: if not activity.activity_type.manage_entries:
@ -186,8 +192,8 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
guest_qs = Guest.objects\ guest_qs = Guest.objects\
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
.filter(activity=activity)\ .filter(activity=activity)\
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ .filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
.order_by('last_name', 'first_name').distinct() .order_by('last_name', 'first_name')
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
@ -201,7 +207,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
) )
else: else:
guest_qs = guest_qs.none() guest_qs = guest_qs.none()
return guest_qs return guest_qs.distinct()
def get_invited_note(self, activity): def get_invited_note(self, activity):
""" """
@ -225,7 +231,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
) )
# Filter with permission backend # Filter with permission backend
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view"))
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
@ -251,7 +257,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"]) .distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity context["activity"] = activity
@ -276,15 +282,17 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
activities_open = Activity.objects.filter(open=True).filter( activities_open = Activity.objects.filter(open=True).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
context["activities_open"] = [a for a in activities_open context["activities_open"] = [a for a in activities_open
if PermissionBackend.check_perm(self.request.user, if PermissionBackend.check_perm(self.request,
"activity.add_entry", "activity.add_entry",
Entry(activity=a, note=self.request.user.note,))] Entry(activity=a, note=self.request.user.note,))]
return context return context
# Cache for 1 hour
@method_decorator(cache_page(60 * 60), name='dispatch')
class CalendarView(View): class CalendarView(View):
""" """
Render an ICS calendar with all valid activities. Render an ICS calendar with all valid activities.

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'api.apps.APIConfig' default_app_config = 'api.apps.APIConfig'

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig

5
apps/api/pagination.py Normal file
View File

@ -0,0 +1,5 @@
from rest_framework.pagination import PageNumberPagination
class CustomPagination(PageNumberPagination):
page_size_query_param = 'page_size'

View File

@ -1,13 +1,20 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework.serializers import ModelSerializer 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(ModelSerializer): class UserSerializer(serializers.ModelSerializer):
""" """
REST API Serializer for Users. REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API. The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
@ -22,7 +29,7 @@ class UserSerializer(ModelSerializer):
) )
class ContentTypeSerializer(ModelSerializer): class ContentTypeSerializer(serializers.ModelSerializer):
""" """
REST API Serializer for Users. REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API. The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
@ -31,3 +38,54 @@ class ContentTypeSerializer(ModelSerializer):
class Meta: class Meta:
model = ContentType model = ContentType
fields = '__all__' fields = '__all__'
class OAuthSerializer(serializers.ModelSerializer):
"""
Informations that are transmitted by OAuth.
For now, this includes user, profile and valid memberships.
This should be better managed later.
"""
normalized_name = serializers.SerializerMethodField()
profile = serializers.SerializerMethodField()
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())
.filter(PermissionBackend.filter_queryset(get_current_request(), Membership, 'view')))
class Meta:
model = User
fields = (
'id',
'username',
'normalized_name',
'first_name',
'last_name',
'email',
'is_superuser',
'is_active',
'is_staff',
'profile',
'note',
'memberships',
)

240
apps/api/tests.py Normal file
View File

@ -0,0 +1,240 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import json
from datetime import datetime, date
from decimal import Decimal
from urllib.parse import quote_plus
from warnings import warn
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db.models.fields.files import ImageFieldFile
from django.test import TestCase
from django_filters.rest_framework import DjangoFilterBackend
from member.models import Membership, Club
from note.models import NoteClub, NoteUser, Alias, Note
from permission.models import PermissionMask, Permission, Role
from phonenumbers import PhoneNumber
from rest_framework.filters import SearchFilter, OrderingFilter
from .viewsets import ContentTypeViewSet, UserViewSet
class TestAPI(TestCase):
"""
Load API pages and check that filters are working.
"""
fixtures = ('initial', )
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="adminapi",
password="adminapi",
email="adminapi@example.com",
last_name="Admin",
first_name="Admin",
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
def check_viewset(self, viewset, url):
"""
This function should be called inside a unit test.
This loads the viewset and for each filter entry, it checks that the filter is running good.
"""
resp = self.client.get(url + "?format=json")
self.assertEqual(resp.status_code, 200)
model = viewset.serializer_class.Meta.model
if not model.objects.exists(): # pragma: no cover
warn(f"Warning: unable to test API filters for the model {model._meta.verbose_name} "
"since there is no instance of it.")
return
if hasattr(viewset, "filter_backends"):
backends = viewset.filter_backends
obj = model.objects.last()
if DjangoFilterBackend in backends:
# Specific search
for field in viewset.filterset_fields:
obj = self.fix_note_object(obj, field)
value = self.get_value(obj, field)
if value is None: # pragma: no cover
warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} "
"has not been tested.")
continue
resp = self.client.get(url + f"?format=json&{field}={quote_plus(str(value))}")
self.assertEqual(resp.status_code, 200, f"The filter {field} for the model "
f"{model._meta.verbose_name} does not work. "
f"Given parameter: {value}")
content = json.loads(resp.content)
self.assertGreater(content["count"], 0, f"The filter {field} for the model "
f"{model._meta.verbose_name} does not work. "
f"Given parameter: {value}")
if OrderingFilter in backends:
# Ensure that ordering is working well
for field in viewset.ordering_fields:
resp = self.client.get(url + f"?ordering={field}")
self.assertEqual(resp.status_code, 200)
resp = self.client.get(url + f"?ordering=-{field}")
self.assertEqual(resp.status_code, 200)
if SearchFilter in backends:
# Basic search
for field in viewset.search_fields:
obj = self.fix_note_object(obj, field)
if field[0] == '$' or field[0] == '=':
field = field[1:]
value = self.get_value(obj, field)
if value is None: # pragma: no cover
warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} "
"has not been tested.")
continue
resp = self.client.get(url + f"?format=json&search={quote_plus(str(value))}")
self.assertEqual(resp.status_code, 200, f"The filter {field} for the model "
f"{model._meta.verbose_name} does not work. "
f"Given parameter: {value}")
content = json.loads(resp.content)
self.assertGreater(content["count"], 0, f"The filter {field} for the model "
f"{model._meta.verbose_name} does not work. "
f"Given parameter: {value}")
self.check_permissions(url, obj)
def check_permissions(self, url, obj):
"""
Check that permissions are working
"""
# Drop rights
self.user.is_superuser = False
self.user.save()
sess = self.client.session
sess["permission_mask"] = 0
sess.save()
# Delete user permissions
for m in Membership.objects.filter(user=self.user).all():
m.roles.clear()
m.save()
# Create a new role, which will have the checking permission
role = Role.objects.get_or_create(name="β-tester")[0]
role.permissions.clear()
role.save()
membership = Membership.objects.get_or_create(user=self.user, club=Club.objects.get(name="BDE"))[0]
membership.roles.set([role])
membership.save()
# Ensure that the access to the object is forbidden without permission
resp = self.client.get(url + f"{obj.pk}/")
self.assertEqual(resp.status_code, 404, f"Mysterious access to {url}{obj.pk}/ for {obj}")
obj.refresh_from_db()
# There are problems with polymorphism
if isinstance(obj, Note) and hasattr(obj, "note_ptr"):
obj = obj.note_ptr
mask = PermissionMask.objects.get(rank=0)
for field in obj._meta.fields:
# Build permission query
value = self.get_value(obj, field.name)
if isinstance(value, date) or isinstance(value, datetime):
value = value.isoformat()
elif isinstance(value, ImageFieldFile):
value = value.name
elif isinstance(value, Decimal):
value = str(value)
query = json.dumps({field.name: value})
# Create sample permission
permission = Permission.objects.get_or_create(
model=ContentType.objects.get_for_model(obj._meta.model),
query=query,
mask=mask,
type="view",
permanent=False,
description=f"Can view {obj._meta.verbose_name}",
)[0]
role.permissions.set([permission])
role.save()
# Check that the access is possible
resp = self.client.get(url + f"{obj.pk}/")
self.assertEqual(resp.status_code, 200, f"Permission {permission.query} is not working "
f"for the model {obj._meta.verbose_name}")
# Restore rights
self.user.is_superuser = True
self.user.save()
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
@staticmethod
def get_value(obj, key: str):
"""
Resolve the queryset filter to get the Python value of an object.
"""
if hasattr(obj, "all"):
# obj is a RelatedManager
obj = obj.last()
if obj is None: # pragma: no cover
return None
if '__' not in key:
obj = getattr(obj, key)
if hasattr(obj, "pk"):
return obj.pk
elif hasattr(obj, "all"):
if not obj.exists(): # pragma: no cover
return None
return obj.last().pk
elif isinstance(obj, bool):
return int(obj)
elif isinstance(obj, datetime):
return obj.isoformat()
elif isinstance(obj, PhoneNumber):
return obj.raw_input
return obj
key, remaining = key.split('__', 1)
return TestAPI.get_value(getattr(obj, key), remaining)
@staticmethod
def fix_note_object(obj, field):
"""
When querying an object that has a noteclub or a noteuser field,
ensure that the object has a good value.
"""
if isinstance(obj, Alias):
if "noteuser" in field:
return NoteUser.objects.last().alias.last()
elif "noteclub" in field:
return NoteClub.objects.last().alias.last()
elif isinstance(obj, Note):
if "noteuser" in field:
return NoteUser.objects.last()
elif "noteclub" in field:
return NoteClub.objects.last()
return obj
class TestBasicAPI(TestAPI):
def test_user_api(self):
"""
Load the user page.
"""
self.check_viewset(ContentTypeViewSet, "/api/models/")
self.check_viewset(UserViewSet, "/api/user/")

View File

@ -1,10 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings from django.conf import settings
from django.conf.urls import url, include from django.conf.urls import url, include
from rest_framework import routers from rest_framework import routers
from .views import UserInformationView
from .viewsets import ContentTypeViewSet, UserViewSet from .viewsets import ContentTypeViewSet, UserViewSet
# Routers provide an easy way of automatically determining the URL conf. # Routers provide an easy way of automatically determining the URL conf.
@ -47,5 +48,6 @@ app_name = 'api'
# Additionally, we include login URLs for the browsable API. # Additionally, we include login URLs for the browsable API.
urlpatterns = [ urlpatterns = [
url('^', include(router.urls)), url('^', include(router.urls)),
url('^me/', UserInformationView.as_view()),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')), url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
] ]

20
apps/api/views.py Normal file
View File

@ -0,0 +1,20 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from rest_framework.generics import RetrieveAPIView
from .serializers import OAuthSerializer
class UserInformationView(RetrieveAPIView):
"""
These fields are give to OAuth authenticators.
"""
serializer_class = OAuthSerializer
def get_queryset(self):
return User.objects.filter(pk=self.request.user.pk)
def get_object(self):
return self.request.user

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -6,9 +6,9 @@ from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Q from django.db.models import Q
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from note_kfet.middlewares import get_current_session
from note.models import Alias from note.models import Alias
from .serializers import UserSerializer, ContentTypeSerializer from .serializers import UserSerializer, ContentTypeSerializer
@ -24,9 +24,7 @@ class ReadProtectedModelViewSet(ModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self): def get_queryset(self):
user = self.request.user return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet): class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
@ -39,21 +37,20 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self): def get_queryset(self):
user = self.request.user return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class UserViewSet(ReadProtectedModelViewSet): class UserViewSet(ReadProtectedModelViewSet):
""" """
REST API View set. REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/ then render it on /api/user/
""" """
queryset = User.objects.all() queryset = User.objects
serializer_class = UserSerializer serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ] filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active',
'note__alias__name', 'note__alias__normalized_name', ]
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
@ -106,7 +103,10 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
""" """
REST API View set. REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/ then render it on /api/models/
""" """
queryset = ContentType.objects.all() queryset = ContentType.objects.order_by('id')
serializer_class = ContentTypeSerializer serializer_class = ContentTypeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['id', 'app_label', 'model', ]
search_fields = ['$app_label', '$model', ]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'logs.apps.LogsConfig' default_app_config = 'logs.apps.LogsConfig'

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers from rest_framework import serializers

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import ChangelogViewSet from .views import ChangelogViewSet

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -15,7 +15,7 @@ class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
then render it on /api/logs/ then render it on /api/logs/
""" """
queryset = Changelog.objects.all() queryset = Changelog.objects.order_by('id')
serializer_class = ChangelogSerializer serializer_class = ChangelogSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter] filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ] filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings from django.conf import settings

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'member.apps.MemberConfig' default_app_config = 'member.apps.MemberConfig'

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin from django.contrib import admin

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers from rest_framework import serializers

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import ProfileViewSet, ClubViewSet, MembershipViewSet from .views import ProfileViewSet, ClubViewSet, MembershipViewSet

View File

@ -1,7 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework.filters import SearchFilter from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
@ -14,8 +15,15 @@ class ProfileViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer,
then render it on /api/members/profile/ then render it on /api/members/profile/
""" """
queryset = Profile.objects.all() queryset = Profile.objects.order_by('id')
serializer_class = ProfileSerializer serializer_class = ProfileSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section",
'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
'ml_art_registration', 'report_frequency', 'email_confirmed', 'registration_valid', ]
search_fields = ['$user__first_name', '$user__last_name', '$user__username', '$user__email',
'$user__note__alias__name', '$user__note__alias__normalized_name', ]
class ClubViewSet(ReadProtectedModelViewSet): class ClubViewSet(ReadProtectedModelViewSet):
@ -24,10 +32,13 @@ class ClubViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer,
then render it on /api/members/club/ then render it on /api/members/club/
""" """
queryset = Club.objects.all() queryset = Club.objects.order_by('id')
serializer_class = ClubSerializer serializer_class = ClubSerializer
filter_backends = [SearchFilter] filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ['$name', ] filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
'membership_duration', 'membership_start', 'membership_end', ]
search_fields = ['$name', '$email', '$note__alias__name', '$note__alias__normalized_name', ]
class MembershipViewSet(ReadProtectedModelViewSet): class MembershipViewSet(ReadProtectedModelViewSet):
@ -36,5 +47,14 @@ class MembershipViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer,
then render it on /api/members/membership/ then render it on /api/members/membership/
""" """
queryset = Membership.objects.all() queryset = Membership.objects.order_by('id')
serializer_class = MembershipSerializer serializer_class = MembershipSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name',
'user__username', 'user__last_name', 'user__first_name', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name',
'date_start', 'date_end', 'fee', 'roles', ]
ordering_fields = ['id', 'date_start', 'date_end', ]
search_fields = ['$club__name', '$club__email', '$club__note__alias__name', '$club__note__alias__normalized_name',
'$user__username', '$user__last_name', '$user__first_name', '$user__email',
'$user__note__alias__name', '$user__note__alias__normalized_name', '$roles__name', ]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig

17
apps/member/auth.py Normal file
View File

@ -0,0 +1,17 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from cas_server.auth import DjangoAuthUser # pragma: no cover
from note.models import Alias
class CustomAuthUser(DjangoAuthUser): # pragma: no cover
"""
Override Django Auth User model to define a custom Matrix username.
"""
def attributs(self):
d = super().attributs()
if self.user:
d["normalized_name"] = Alias.normalize(self.user.username)
return d

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import io import io
@ -8,6 +8,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import transaction
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -57,6 +58,7 @@ class ProfileForm(forms.ModelForm):
self.fields['address'].widget.attrs.update({"placeholder": "4 avenue des Sciences, 91190 GIF-SUR-YVETTE"}) self.fields['address'].widget.attrs.update({"placeholder": "4 avenue des Sciences, 91190 GIF-SUR-YVETTE"})
self.fields['promotion'].widget.attrs.update({"max": timezone.now().year}) self.fields['promotion'].widget.attrs.update({"max": timezone.now().year})
@transaction.atomic
def save(self, commit=True): def save(self, commit=True):
if not self.instance.section or (("department" in self.changed_data if not self.instance.section or (("department" in self.changed_data
or "promotion" in self.changed_data) and "section" not in self.changed_data): or "promotion" in self.changed_data) and "section" not in self.changed_data):
@ -148,6 +150,7 @@ class ClubForm(forms.ModelForm):
"membership_fee_unpaid": AmountInput(), "membership_fee_unpaid": AmountInput(),
"parent_club": Autocomplete( "parent_club": Autocomplete(
Club, Club,
resetable=True,
attrs={ attrs={
'api_url': '/api/members/club/', 'api_url': '/api/members/club/',
} }
@ -161,7 +164,7 @@ class MembershipForm(forms.ModelForm):
soge = forms.BooleanField( soge = forms.BooleanField(
label=_("Inscription paid by Société Générale"), label=_("Inscription paid by Société Générale"),
required=False, required=False,
help_text=_("Check this case is the Société Générale paid the inscription."), help_text=_("Check this case if the Société Générale paid the inscription."),
) )
credit_type = forms.ModelChoiceField( credit_type = forms.ModelChoiceField(

View File

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

View File

@ -7,6 +7,7 @@ def create_bde_and_kfet(apps, schema_editor):
""" """
Club = apps.get_model("member", "club") Club = apps.get_model("member", "club")
NoteClub = apps.get_model("note", "noteclub") NoteClub = apps.get_model("note", "noteclub")
Alias = apps.get_model("note", "alias")
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
@ -18,8 +19,8 @@ def create_bde_and_kfet(apps, schema_editor):
membership_fee_paid=500, membership_fee_paid=500,
membership_fee_unpaid=500, membership_fee_unpaid=500,
membership_duration=396, membership_duration=396,
membership_start="2020-08-01", membership_start="2021-08-01",
membership_end="2021-09-30", membership_end="2022-09-30",
) )
Club.objects.get_or_create( Club.objects.get_or_create(
id=2, id=2,
@ -30,8 +31,8 @@ def create_bde_and_kfet(apps, schema_editor):
membership_fee_paid=3500, membership_fee_paid=3500,
membership_fee_unpaid=3500, membership_fee_unpaid=3500,
membership_duration=396, membership_duration=396,
membership_start="2020-08-01", membership_start="2021-08-01",
membership_end="2021-09-30", membership_end="2022-09-30",
) )
NoteClub.objects.get_or_create( NoteClub.objects.get_or_create(
@ -45,6 +46,19 @@ def create_bde_and_kfet(apps, schema_editor):
polymorphic_ctype_id=polymorphic_ctype_id, polymorphic_ctype_id=polymorphic_ctype_id,
) )
Alias.objects.get_or_create(
id=5,
note_id=5,
name="BDE",
normalized_name="bde",
)
Alias.objects.get_or_create(
id=6,
note_id=6,
name="Kfet",
normalized_name="kfet",
)
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [

View File

@ -0,0 +1,50 @@
import sys
from django.db import migrations
def give_note_account_permissions(apps, schema_editor):
"""
Automatically manage the membership of the Note account.
"""
User = apps.get_model("auth", "user")
Membership = apps.get_model("member", "membership")
Role = apps.get_model("permission", "role")
note = User.objects.filter(username="note")
if not note.exists():
# We are in a test environment, don't log error message
if len(sys.argv) > 1 and sys.argv[1] == 'test':
return
print("Warning: Note account was not found. The note account was not imported.")
print("Make sure you have imported the NK15 database. The new import script handles correctly the permissions.")
print("This migration will be ignored, you can re-run it if you forgot the note account or ignore it if you "
"don't want this account.")
return
note = note.get()
# Set for the two clubs a large expiration date and the correct role.
for m in Membership.objects.filter(user_id=note.id).all():
m.date_end = "3142-12-12"
m.roles.set(Role.objects.filter(name="PC Kfet").all())
m.save()
# By default, the note account is only authorized to be logged from localhost.
note.password = "ipbased$127.0.0.1"
note.is_active = True
note.save()
# Ensure that the note of the account is disabled
note.note.inactivity_reason = 'forced'
note.note.is_active = False
note.save()
class Migration(migrations.Migration):
dependencies = [
('member', '0005_remove_null_tag_on_charfields'),
('permission', '0001_initial'),
]
operations = [
migrations.RunPython(give_note_account_permissions),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.19 on 2021-03-13 11:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0006_create_note_account_bde_membership'),
]
operations = [
migrations.AlterField(
model_name='membership',
name='roles',
field=models.ManyToManyField(related_name='memberships', to='permission.Role', verbose_name='roles'),
),
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2021, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

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

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import datetime import datetime
@ -7,7 +7,7 @@ import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
from django.template import loader from django.template import loader
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
@ -57,7 +57,7 @@ class Profile(models.Model):
('A1', _("Mathematics (A1)")), ('A1', _("Mathematics (A1)")),
('A2', _("Physics (A2)")), ('A2', _("Physics (A2)")),
("A'2", _("Applied physics (A'2)")), ("A'2", _("Applied physics (A'2)")),
('A''2', _("Chemistry (A''2)")), ("A''2", _("Chemistry (A''2)")),
('A3', _("Biology (A3)")), ('A3', _("Biology (A3)")),
('B1234', _("SAPHIRE (B1234)")), ('B1234', _("SAPHIRE (B1234)")),
('B1', _("Mechanics (B1)")), ('B1', _("Mechanics (B1)")),
@ -74,7 +74,7 @@ class Profile(models.Model):
promotion = models.PositiveSmallIntegerField( promotion = models.PositiveSmallIntegerField(
null=True, null=True,
default=datetime.date.today().year, default=datetime.date.today().year if datetime.date.today().month >= 8 else datetime.date.today().year - 1,
verbose_name=_("promotion"), verbose_name=_("promotion"),
help_text=_("Year of entry to the school (None if not ENS student)"), help_text=_("Year of entry to the school (None if not ENS student)"),
) )
@ -258,19 +258,22 @@ class Club(models.Model):
This function is called each time the club detail view is displayed. This function is called each time the club detail view is displayed.
Update the year of the membership dates. Update the year of the membership dates.
""" """
if not self.membership_start: if not self.membership_start or not self.membership_end:
return return
today = datetime.date.today() today = datetime.date.today()
if (today - self.membership_start).days >= 365: if (today - self.membership_start).days >= 365:
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1, self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day) self.membership_start.month, self.membership_start.day)
if self.membership_end:
self.membership_end = datetime.date(self.membership_end.year + 1, self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day) self.membership_end.month, self.membership_end.day)
self._force_save = True self._force_save = True
self.save(force_update=True) self.save(force_update=True)
@transaction.atomic
def save(self, force_insert=False, force_update=False, using=None, def save(self, force_insert=False, force_update=False, using=None,
update_fields=None): update_fields=None):
if not self.require_memberships: if not self.require_memberships:
@ -312,6 +315,7 @@ class Membership(models.Model):
roles = models.ManyToManyField( roles = models.ManyToManyField(
"permission.Role", "permission.Role",
related_name="memberships",
verbose_name=_("roles"), verbose_name=_("roles"),
) )
@ -406,10 +410,17 @@ class Membership(models.Model):
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all()) parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
parent_membership.save() parent_membership.save()
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
Calculate fee and end date before saving the membership and creating the transaction if needed. Calculate fee and end date before saving the membership and creating the transaction if needed.
""" """
# Ensure that club membership dates are valid
old_membership_start = self.club.membership_start
self.club.update_membership_dates()
if self.club.membership_start != old_membership_start:
self.club.save()
created = not self.pk created = not self.pk
if not created: if not created:
for role in self.roles.all(): for role in self.roles.all():
@ -475,7 +486,12 @@ class Membership(models.Model):
# to treasurers. # to treasurers.
transaction.valid = False transaction.valid = False
from treasury.models import SogeCredit from treasury.models import SogeCredit
soge_credit = SogeCredit.objects.get_or_create(user=self.user)[0] if SogeCredit.objects.filter(user=self.user).exists():
soge_credit = SogeCredit.objects.get(user=self.user)
else:
soge_credit = SogeCredit(user=self.user)
soge_credit._force_save = True
soge_credit.save(force_insert=True)
soge_credit.refresh_from_db() soge_credit.refresh_from_db()
transaction.save(force_insert=True) transaction.save(force_insert=True)
transaction.refresh_from_db() transaction.refresh_from_db()

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -14,7 +14,7 @@ function create_alias (e) {
}).done(function () { }).done(function () {
// Reload table // Reload table
$('#alias_table').load(location.pathname + ' #alias_table') $('#alias_table').load(location.pathname + ' #alias_table')
addMsg('Alias ajouté', 'success') addMsg(gettext('Alias successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) { }).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON) errMsg(xhr.responseJSON)
}) })
@ -22,7 +22,7 @@ function create_alias (e) {
/** /**
* On click of "delete", delete the alias * On click of "delete", delete the alias
* @param Integer button_id Alias id to remove * @param button_id:Integer Alias id to remove
*/ */
function delete_button (button_id) { function delete_button (button_id) {
$.ajax({ $.ajax({
@ -30,7 +30,7 @@ function delete_button (button_id) {
method: 'DELETE', method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN } headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () { }).done(function () {
addMsg('Alias supprimé', 'success') addMsg(gettext('Alias successfully deleted'), 'success')
$('#alias_table').load(location.pathname + ' #alias_table') $('#alias_table').load(location.pathname + ' #alias_table')
}).fail(function (xhr, _textStatus, _error) { }).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON) errMsg(xhr.responseJSON)

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

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date from datetime import date
@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.html import format_html from django.utils.html import format_html
from note.templatetags.pretty_money import pretty_money from note.templatetags.pretty_money import pretty_money
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import Club, Membership from .models import Club, Membership
@ -31,7 +31,8 @@ class ClubTable(tables.Table):
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'id': lambda record: "row-" + str(record.pk), 'id': lambda record: "row-" + str(record.pk),
'data-href': lambda record: record.pk 'data-href': lambda record: record.pk,
'style': 'cursor:pointer',
} }
@ -43,11 +44,27 @@ class UserTable(tables.Table):
section = tables.Column(accessor='profile__section') section = tables.Column(accessor='profile__section')
# Override the column to let replace the URL
email = tables.EmailColumn(linkify=lambda record: "mailto:{}".format(record.email))
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance")) balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
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_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_request(), "member.view_profile", record.profile) \
else ""
def render_balance(self, record, value): def render_balance(self, record, value):
return pretty_money(value)\ return pretty_money(value)\
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", record.note) else "" if PermissionBackend.check_perm(get_current_request(), "note.view_note", record.note) else ""
class Meta: class Meta:
attrs = { attrs = {
@ -58,7 +75,8 @@ class UserTable(tables.Table):
model = User model = User
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'data-href': lambda record: record.pk 'data-href': lambda record: record.pk,
'style': 'cursor:pointer',
} }
@ -77,7 +95,7 @@ class MembershipTable(tables.Table):
def render_user(self, value): def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.username s = value.username
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
@ -86,7 +104,7 @@ class MembershipTable(tables.Table):
def render_club(self, value): def render_club(self, value):
# If the user has the right, link the displayed club with the page of its detail. # If the user has the right, link the displayed club with the page of its detail.
s = value.name s = value.name
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value): if PermissionBackend.check_perm(get_current_request(), "member.view_club", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
@ -102,7 +120,7 @@ class MembershipTable(tables.Table):
club=record.club, club=record.club,
user=record.user, user=record.user,
date_start__gte=record.club.membership_start, date_start__gte=record.club.membership_start,
date_end__lte=record.club.membership_end, date_end__lte=record.club.membership_end or date(9999, 12, 31),
).exists(): # If the renew is not yet performed ).exists(): # If the renew is not yet performed
empty_membership = Membership( empty_membership = Membership(
club=record.club, club=record.club,
@ -111,8 +129,8 @@ class MembershipTable(tables.Table):
date_end=date.today(), date_end=date.today(),
fee=0, fee=0,
) )
if PermissionBackend.check_perm(get_current_authenticated_user(), if PermissionBackend.check_perm(get_current_request(),
"member:add_membership", empty_membership): # If the user has right "member.add_membership", empty_membership): # If the user has right
renew_url = reverse_lazy('member:club_renew_membership', renew_url = reverse_lazy('member:club_renew_membership',
kwargs={"pk": record.pk}) kwargs={"pk": record.pk})
t = format_html( t = format_html(
@ -126,7 +144,7 @@ class MembershipTable(tables.Table):
# If the user has the right to manage the roles, display the link to manage them # If the user has the right to manage the roles, display the link to manage them
roles = record.roles.all() roles = record.roles.all()
s = ", ".join(str(role) for role in roles) s = ", ".join(str(role) for role in roles)
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record): if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record):
s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk})) s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
+ "'>" + s + "</a>") + "'>" + s + "</a>")
return s return s
@ -149,7 +167,7 @@ class ClubManagerTable(tables.Table):
def render_user(self, value): def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.username s = value.username
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
s = format_html("<a href={url}>{name}</a>", s = format_html("<a href={url}>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)

View File

@ -13,15 +13,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if additional_fee_renewal %} {% if additional_fee_renewal %}
<div class="alert alert-warning"> <div class="alert alert-warning">
{% if renewal %} {% if renewal %}
{% if club.name == "Kfet" %} {# Auto-renewal #}
{% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %} {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
The user is not a member of the club·s {{ clubs }}. An additional fee of {{ pretty_fee }} The user is not a member of the club·s {{ clubs }}. An additional fee of {{ pretty_fee }}
will be charged to renew automatically the membership in this/these club·s. will be charged to renew automatically the membership in this/these club·s.
{% endblocktrans %} {% endblocktrans %}
{% else %} {% else %}
{% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
The user is not a member of the club·s {{ clubs }}. Please create the required memberships,
otherwise it will fail.
{% endblocktrans %}
{% endif %}
{% else %}
{% if club.name == "Kfet" %}
{% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %} {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
This club has parents {{ clubs }}. An additional fee of {{ pretty_fee }} This club has parents {{ clubs }}. An additional fee of {{ pretty_fee }}
will be charged to adhere automatically to this/these club·s. will be charged to adhere automatically to this/these club·s.
{% endblocktrans %} {% endblocktrans %}
{% else %}
{% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
This club has parents {{ clubs }}. Please make sure that the user is a member of this or these club·s,
otherwise the creation of this membership will fail.
{% endblocktrans %}
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -48,7 +48,7 @@
<dd class="col-xl-6"> <dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}"> <a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}">
<i class="fa fa-edit"></i> <i class="fa fa-edit"></i>
{% trans 'Manage aliases' %} ({{ club.note.alias_set.all|length }}) {% trans 'Manage aliases' %} ({{ club.note.alias.all|length }})
</a> </a>
</dd> </dd>

View File

@ -21,10 +21,19 @@
<dd class="col-xl-6"> <dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:user_alias' user_object.pk %}"> <a class="badge badge-secondary" href="{% url 'member:user_alias' user_object.pk %}">
<i class="fa fa-edit"></i> <i class="fa fa-edit"></i>
{% trans 'Manage aliases' %} ({{ user_object.note.alias_set.all|length }}) {% trans 'Manage aliases' %} ({{ user_object.note.alias.all|length }})
</a> </a>
</dd> </dd>
<dt class="col-xl-6">{% trans 'friendships'|capfirst %}</dt>
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:user_trust' user_object.pk %}">
<i class="fa fa-edit"></i>
{% trans 'Manage friendships' %} ({{ user_object.note.trusting.all|length }})
</a>
</dd>
{% if "member.view_profile"|has_perm:user_object.profile %}
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.section }}</dd> <dd class="col-xl-6">{{ user_object.profile.section }}</dd>
@ -38,16 +47,17 @@
<dt class="col-xl-6">{% trans 'address'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.address }}</dd> <dd class="col-xl-6">{{ user_object.profile.address }}</dd>
{% if "note.view_note"|has_perm:user_object.note %}
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
<dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd> <dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd>
{% endif %} {% endif %}
{% if user_object.note and "note.view_note"|has_perm:user_object.note %}
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
{% endif %}
</dl> </dl>
{% if user_object.pk == user_object.pk %} {% if user_object.pk == user.pk %}
<div class="text-center"> <div class="text-center">
<a class="small badge badge-secondary" href="{% url 'member:auth_token' %}"> <a class="small badge badge-secondary" href="{% url 'member:auth_token' %}">
<i class="fa fa-cogs"></i>{% trans 'API token' %} <i class="fa fa-cogs"></i>{% trans 'API token' %}

View File

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

View File

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

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n perms %} {% load i18n perms %}
{% block content %} {% block content %}
{% if "member.change_profile_registration_valid"|has_perm:user %} {% if can_manage_registrations %}
<a class="btn btn-block btn-secondary mb-3" href="{% url 'registration:future_user_list' %}"> <a class="btn btn-block btn-secondary mb-3" href="{% url 'registration:future_user_list' %}">
<i class="fa fa-user-plus"></i> {% trans "Registrations" %} <i class="fa fa-user-plus"></i> {% trans "Registrations" %}
</a> </a>

View File

View File

@ -0,0 +1,22 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from django import template
from django.contrib.auth.models import User
from ..models import Club, Membership
def is_member(user, club):
if isinstance(user, str):
club = User.objects.get(username=user)
if isinstance(club, str):
club = Club.objects.get(name=club)
return Membership.objects\
.filter(user=user, club=club, date_start__lte=date.today(), date_end__gte=date.today()).exists()
register = template.Library()
register.filter("is_member", is_member)

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -41,7 +41,7 @@ class TemplateLoggedInTests(TestCase):
password="adminadmin", password="adminadmin",
permission_mask=3, permission_mask=3,
)) ))
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 200) self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
def test_logout(self): def test_logout(self):
response = self.client.get(reverse("logout")) response = self.client.get(reverse("logout"))

View File

@ -1,21 +1,24 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import hashlib import hashlib
import os import os
from datetime import date, timedelta from datetime import date, timedelta
from api.tests import TestAPI
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Q from django.db.models import Q
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from member.models import Club, Membership, Profile
from note.models import Alias, NoteSpecial from note.models import Alias, NoteSpecial
from permission.models import Role from permission.models import Role
from treasury.models import SogeCredit from treasury.models import SogeCredit
from ..api.views import ClubViewSet, MembershipViewSet, ProfileViewSet
from ..models import Club, Membership, Profile
""" """
Create some users and clubs and test that all pages are rendering properly Create some users and clubs and test that all pages are rendering properly
and that memberships are working. and that memberships are working.
@ -205,7 +208,7 @@ class TestMemberships(TestCase):
first_name="Toto", first_name="Toto",
bank="Le matelas", bank="Le matelas",
)) ))
self.assertRedirects(response, club.get_absolute_url(), 302, 200) self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
self.assertTrue(Membership.objects.filter(user=user, club=club).exists()) self.assertTrue(Membership.objects.filter(user=user, club=club).exists())
@ -244,9 +247,9 @@ class TestMemberships(TestCase):
first_name="Toto", first_name="Toto",
bank="Bank", bank="Bank",
)) ))
self.assertRedirects(response, club.get_absolute_url(), 302, 200) self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
response = self.client.get(user.profile.get_absolute_url()) response = self.client.get(club.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_auto_join_kfet_when_join_bde_with_soge(self): def test_auto_join_kfet_when_join_bde_with_soge(self):
@ -273,7 +276,7 @@ class TestMemberships(TestCase):
first_name="Toto", first_name="Toto",
bank="Société générale", bank="Société générale",
)) ))
self.assertRedirects(response, bde.get_absolute_url(), 302, 200) self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
self.assertTrue(Membership.objects.filter(user=user, club=bde).exists()) self.assertTrue(Membership.objects.filter(user=user, club=bde).exists())
self.assertTrue(Membership.objects.filter(user=user, club=kfet).exists()) self.assertTrue(Membership.objects.filter(user=user, club=kfet).exists())
@ -403,3 +406,46 @@ class TestMemberships(TestCase):
self.user.password = "custom_nk15$1$" + salt + "|" + hashed self.user.password = "custom_nk15$1$" + salt + "|" + hashed
self.user.save() self.user.save()
self.assertTrue(self.user.check_password(password)) self.assertTrue(self.user.check_password(password))
class TestMemberAPI(TestAPI):
def setUp(self) -> None:
super().setUp()
self.user.profile.registration_valid = True
self.user.profile.email_confirmed = True
self.user.profile.phone_number = "0600000000"
self.user.profile.section = "1A0"
self.user.profile.department = "A0"
self.user.profile.address = "Earth"
self.user.profile.save()
self.club = Club.objects.create(
name="totoclub",
parent_club=Club.objects.get(name="BDE"),
membership_start=date(year=1970, month=1, day=1),
membership_end=date(year=2040, month=1, day=1),
membership_duration=365 * 10,
)
self.bde_membership = Membership.objects.create(user=self.user, club=Club.objects.get(name="BDE"))
self.membership = Membership.objects.create(user=self.user, club=self.club)
self.membership.roles.add(Role.objects.get(name="Bureau de club"))
self.membership.save()
def test_club_api(self):
"""
Load Club API page and test all filters and permissions
"""
self.check_viewset(ClubViewSet, "/api/members/club/")
def test_profile_api(self):
"""
Load Profile API page and test all filters and permissions
"""
self.check_viewset(ProfileViewSet, "/api/members/profile/")
def test_membership_api(self):
"""
Load Membership API page and test all filters and permissions
"""
self.check_viewset(MembershipViewSet, "/api/members/membership/")

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path from django.urls import path
@ -23,5 +23,6 @@ urlpatterns = [
path('user/<int:pk>/update/', views.UserUpdateView.as_view(), name="user_update_profile"), path('user/<int:pk>/update/', views.UserUpdateView.as_view(), name="user_update_profile"),
path('user/<int:pk>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"), path('user/<int:pk>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"), path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
path('user/<int:pk>/trust', views.ProfileTrustView.as_view(), name="user_trust"),
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'), path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'),
] ]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta, date from datetime import timedelta, date
@ -8,6 +8,7 @@ from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from django.db.models import Q, F from django.db.models import Q, F
from django.shortcuts import redirect from django.shortcuts import redirect
@ -18,10 +19,10 @@ from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django_tables2.views import SingleTableView from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from note.models import Alias, NoteUser from note.models import Alias, NoteClub, NoteUser, Trust
from note.models.transactions import Transaction, SpecialTransaction from note.models.transactions import Transaction, SpecialTransaction
from note.tables import HistoryTable, AliasTable from note.tables import HistoryTable, AliasTable, TrustTable
from note_kfet.middlewares import _set_current_user_and_ip from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.models import Role from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
@ -38,9 +39,11 @@ class CustomLoginView(LoginView):
""" """
form_class = CustomAuthenticationForm form_class = CustomAuthenticationForm
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
logout(self.request) logout(self.request)
_set_current_user_and_ip(form.get_user(), self.request.session, None) self.request.user = form.get_user()
_set_current_request(self.request)
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
return super().form_valid(form) return super().form_valid(form)
@ -69,6 +72,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.fields['email'].required = True form.fields['email'].required = True
form.fields['email'].help_text = _("This address must be valid.") form.fields['email'].help_text = _("This address must be valid.")
if PermissionBackend.check_perm(self.request, "member.change_profile", context['user_object'].profile):
context['profile_form'] = self.profile_form(instance=context['user_object'].profile, context['profile_form'] = self.profile_form(instance=context['user_object'].profile,
data=self.request.POST if self.request.POST else None) data=self.request.POST if self.request.POST else None)
if not self.object.profile.report_frequency: if not self.object.profile.report_frequency:
@ -76,6 +80,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return context return context
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
""" """
Check if ProfileForm is correct Check if ProfileForm is correct
@ -150,13 +155,17 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
history_list = \ history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\ Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\
.order_by("-created_at")\ .order_by("-created_at")\
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")) .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))
history_table = HistoryTable(history_list, prefix='transaction-') history_table = HistoryTable(history_list, prefix='transaction-')
history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1)) history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1))
context['history_list'] = history_table context['history_list'] = history_table
club_list = Membership.objects.filter(user=user, date_end__gte=date.today())\ 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")\
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else club_list
membership_table = MembershipTable(data=club_list, prefix='membership-') membership_table = MembershipTable(data=club_list, prefix='membership-')
membership_table.paginate(per_page=10, page=self.request.GET.get("membership-page", 1)) membership_table.paginate(per_page=10, page=self.request.GET.get("membership-page", 1))
context['club_list'] = membership_table context['club_list'] = membership_table
@ -164,23 +173,25 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
# Check permissions to see if the authenticated user can lock/unlock the note # Check permissions to see if the authenticated user can lock/unlock the note
with transaction.atomic(): with transaction.atomic():
modified_note = NoteUser.objects.get(pk=user.note.pk) modified_note = NoteUser.objects.get(pk=user.note.pk)
modified_note.is_active = True # Don't log these tests
modified_note._no_signal = True
modified_note.is_active = False
modified_note.inactivity_reason = 'manual' modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = user.note.is_active and PermissionBackend\ context["can_lock_note"] = user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_noteuser_is_active", .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
modified_note)
old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk) old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk)
modified_note.inactivity_reason = 'forced' modified_note.inactivity_reason = 'forced'
modified_note._force_save = True modified_note._force_save = True
modified_note.save() modified_note.save()
context["can_force_lock"] = user.note.is_active and PermissionBackend\ context["can_force_lock"] = user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_note_is_active", modified_note) .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
old_note._force_save = True old_note._force_save = True
old_note._no_signal = True
old_note.save() old_note.save()
modified_note.refresh_from_db() modified_note.refresh_from_db()
modified_note.is_active = True modified_note.is_active = True
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\ context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_note_is_active", modified_note) .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
return context return context
@ -225,6 +236,46 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
return qs return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
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): class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
@ -238,9 +289,10 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
note = context['object'].note note = context['object'].note
context["aliases"] = AliasTable(note.alias_set.filter(PermissionBackend context["aliases"] = AliasTable(
.filter_queryset(self.request.user, Alias, "view")).all()) note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( .order_by('normalized_name').all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note, note=context["object"].note,
name="", name="",
normalized_name="", normalized_name="",
@ -269,6 +321,7 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
self.object = self.get_object() self.object = self.get_object()
return self.form_valid(form) if form.is_valid() else self.form_invalid(form) return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
"""Save image to note""" """Save image to note"""
image = form.cleaned_data['image'] image = form.cleaned_data['image']
@ -364,7 +417,7 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["can_add_club"] = PermissionBackend.check_perm(self.request.user, "member.add_club", Club( context["can_add_club"] = PermissionBackend.check_perm(self.request, "member.add_club", Club(
name="", name="",
email="club@example.com", email="club@example.com",
)) ))
@ -385,16 +438,20 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = context["club"] club = self.object
if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club): context["note"] = club.note
if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
club.update_membership_dates() club.update_membership_dates()
# managers list # managers list
managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club")\ managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club",
date_start__lte=date.today(), date_end__gte=date.today())\
.order_by('user__last_name').all() .order_by('user__last_name').all()
context["managers"] = ClubManagerTable(data=managers, prefix="managers-") context["managers"] = ClubManagerTable(data=managers, prefix="managers-")
# transaction history # transaction history
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\ club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\ .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))\
.order_by('-created_at') .order_by('-created_at')
history_table = HistoryTable(club_transactions, prefix="history-") history_table = HistoryTable(club_transactions, prefix="history-")
history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1)) history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
@ -402,8 +459,12 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
# member list # member list
club_member = Membership.objects.filter( club_member = Membership.objects.filter(
club=club, club=club,
date_end__gte=date.today(), 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")\
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else club_member
membership_table = MembershipTable(data=club_member, prefix="membership-") membership_table = MembershipTable(data=club_member, prefix="membership-")
membership_table.paginate(per_page=5, page=self.request.GET.get('membership-page', 1)) membership_table.paginate(per_page=5, page=self.request.GET.get('membership-page', 1))
@ -420,6 +481,29 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_add_members"] = PermissionBackend()\ context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "member.add_membership", empty_membership) .has_perm(self.request.user, "member.add_membership", empty_membership)
# Check permissions to see if the authenticated user can lock/unlock the note
with transaction.atomic():
modified_note = NoteClub.objects.get(pk=club.note.pk)
# Don't log these tests
modified_note._no_signal = True
modified_note.is_active = False
modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
old_note = NoteClub.objects.select_for_update().get(pk=club.note.pk)
modified_note.inactivity_reason = 'forced'
modified_note._force_save = True
modified_note.save()
context["can_force_lock"] = club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
old_note._force_save = True
old_note._no_signal = True
old_note.save()
modified_note.refresh_from_db()
modified_note.is_active = True
context["can_unlock_note"] = not club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
return context return context
@ -435,9 +519,9 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
note = context['object'].note note = context['object'].note
context["aliases"] = AliasTable(note.alias_set.filter(PermissionBackend context["aliases"] = AliasTable(note.alias.filter(
.filter_queryset(self.request.user, Alias, "view")).all()) PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note, note=context["object"].note,
name="", name="",
normalized_name="", normalized_name="",
@ -512,7 +596,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
form = context['form'] form = context['form']
if "club_pk" in self.kwargs: # We create a new membership. if "club_pk" in self.kwargs: # We create a new membership.
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))\ club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view"))\
.get(pk=self.kwargs["club_pk"], weiclub=None) .get(pk=self.kwargs["club_pk"], weiclub=None)
form.fields['credit_amount'].initial = club.membership_fee_paid form.fields['credit_amount'].initial = club.membership_fee_paid
# Ensure that the user is member of the parent club and all its the family tree. # Ensure that the user is member of the parent club and all its the family tree.
@ -602,11 +686,11 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
# Retrieve form data # Retrieve form data
credit_type = form.cleaned_data["credit_type"] credit_type = form.cleaned_data["credit_type"]
credit_amount = form.cleaned_data["credit_amount"] credit_amount = form.cleaned_data["credit_amount"]
last_name = form.cleaned_data["last_name"]
first_name = form.cleaned_data["first_name"]
bank = form.cleaned_data["bank"]
soge = form.cleaned_data["soge"] and not user.profile.soge and (club.name == "BDE" or club.name == "Kfet") soge = form.cleaned_data["soge"] and not user.profile.soge and (club.name == "BDE" or club.name == "Kfet")
if not credit_type:
credit_amount = 0
if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter( if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter(
club__name="Kfet", club__name="Kfet",
user=user, user=user,
@ -628,6 +712,15 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
form.add_error('user', _('User is already a member of the club')) form.add_error('user', _('User is already a member of the club'))
error = True error = True
# Must join the parent club before joining this club, except for the Kfet club where it can be at the same time.
if club.name != "Kfet" and club.parent_club and not Membership.objects.filter(
user=form.instance.user,
club=club.parent_club,
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
if club.membership_start and form.instance.date_start < club.membership_start: if club.membership_start and form.instance.date_start < club.membership_start:
form.add_error('user', _("The membership must start after {:%m-%d-%Y}.") form.add_error('user', _("The membership must start after {:%m-%d-%Y}.")
.format(form.instance.club.membership_start)) .format(form.instance.club.membership_start))
@ -638,27 +731,23 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
.format(form.instance.club.membership_end)) .format(form.instance.club.membership_end))
error = True error = True
if credit_amount: if credit_amount and not SpecialTransaction.validate_payment_form(form):
if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"): # Check that special information for payment are filled
if not last_name: error = True
form.add_error('last_name', _("This field is required."))
if not first_name:
form.add_error('first_name', _("This field is required."))
if not bank and credit_type.special_type == "Chèque":
form.add_error('bank', _("This field is required."))
return self.form_invalid(form)
return not error return not error
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
""" """
Create membership, check that all is good, make transactions Create membership, check that all is good, make transactions
""" """
# Get the club that is concerned by the membership # Get the club that is concerned by the membership
if "club_pk" in self.kwargs: # get from url of new membership if "club_pk" in self.kwargs: # get from url of new membership
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) \ club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view")) \
.get(pk=self.kwargs["club_pk"]) .get(pk=self.kwargs["club_pk"])
user = form.instance.user user = form.instance.user
old_membership = None
else: # get from url for renewal else: # get from url for renewal
old_membership = self.get_queryset().get(pk=self.kwargs["pk"]) old_membership = self.get_queryset().get(pk=self.kwargs["pk"])
club = old_membership.club club = old_membership.club
@ -706,6 +795,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
# When we renew the BDE membership, we update the profile section # When we renew the BDE membership, we update the profile section
# that should happens at least once a year. # that should happens at least once a year.
user.profile.section = user.profile.section_generated user.profile.section = user.profile.section_generated
user.profile._force_save = True
user.profile.save() user.profile.save()
# Credit note before the membership is created. # Credit note before the membership is created.
@ -733,6 +823,9 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \ 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() \ if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \
if club.name == "Kfet"else Role.objects.filter(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:
member_role = member_role.union(old_membership.roles.all())
form.instance.roles.set(member_role) form.instance.roles.set(member_role)
form.instance._force_save = True form.instance._force_save = True
form.instance.save() form.instance.save()
@ -770,7 +863,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
return ret return ret
def get_success_url(self): def get_success_url(self):
return reverse_lazy('member:club_detail', kwargs={'pk': self.object.club.id}) return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id})
class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
@ -835,7 +928,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = Club.objects.filter( club = Club.objects.filter(
PermissionBackend.filter_queryset(self.request.user, Club, "view") PermissionBackend.filter_queryset(self.request, Club, "view")
).get(pk=self.kwargs["pk"]) ).get(pk=self.kwargs["pk"])
context["club"] = club context["club"] = club

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'note.apps.NoteConfig' default_app_config = 'note.apps.NoteConfig'

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin from django.contrib import admin

View File

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

View File

@ -1,8 +1,9 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import NotePolymorphicViewSet, AliasViewSet, ConsumerViewSet, \ from .views import NotePolymorphicViewSet, AliasViewSet, ConsumerViewSet, \
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet, \
TrustViewSet
def register_note_urls(router, path): def register_note_urls(router, path):
@ -11,6 +12,7 @@ def register_note_urls(router, path):
""" """
router.register(path + '/note', NotePolymorphicViewSet) router.register(path + '/note', NotePolymorphicViewSet)
router.register(path + '/alias', AliasViewSet) router.register(path + '/alias', AliasViewSet)
router.register(path + '/trust', TrustViewSet)
router.register(path + '/consumer', ConsumerViewSet) router.register(path + '/consumer', ConsumerViewSet)
router.register(path + '/transaction/category', TemplateCategoryViewSet) router.register(path + '/transaction/category', TemplateCategoryViewSet)

View File

@ -1,5 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import re
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -9,34 +11,41 @@ from rest_framework import viewsets
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from note_kfet.middlewares import get_current_session
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\ from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer, \
from ..models.notes import Note, Alias TrustSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial, Trust
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
class NotePolymorphicViewSet(ReadProtectedModelViewSet): class NotePolymorphicViewSet(ReadProtectedModelViewSet):
""" """
REST API View set. REST API View set.
The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Note` objects (with polymorhism),
serialize it to JSON with the given serializer,
then render it on /api/note/note/ then render it on /api/note/note/
""" """
queryset = Note.objects.all() queryset = Note.objects.order_by('id')
serializer_class = NotePolymorphicSerializer serializer_class = NotePolymorphicSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ['polymorphic_ctype', 'is_active', ] filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ]
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ] search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model',
ordering_fields = ['alias__name', 'alias__normalized_name'] '$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email',
'$noteuser__user__email', '$noteclub__club__email', ]
ordering_fields = ['alias__name', 'alias__normalized_name', 'balance', 'created_at', ]
def get_queryset(self): def get_queryset(self):
""" """
Parse query and apply filters. Parse query and apply filters.
:return: The filtered set of requested notes :return: The filtered set of requested notes
""" """
queryset = super().get_queryset().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", ".*") alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter( queryset = queryset.filter(
@ -48,23 +57,25 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
return queryset.order_by("id") return queryset.order_by("id")
class AliasViewSet(ReadProtectedModelViewSet): class TrustViewSet(ReadProtectedModelViewSet):
""" """
REST API View set. REST Trust View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Trust` objects, serialize it to JSON with the given serializer,
then render it on /api/aliases/ then render it on /api/note/trust/
""" """
queryset = Alias.objects.all() queryset = Trust.objects
serializer_class = AliasSerializer serializer_class = TrustSerializer
filter_backends = [SearchFilter, OrderingFilter] filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
ordering_fields = ['name', '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): def get_serializer_class(self):
serializer_class = self.serializer_class serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']: if self.request.method in ['PUT', 'PATCH']:
# alias owner cannot be change once establish # trust relationship can't change people involved
setattr(serializer_class.Meta, 'read_only_fields', ('note',)) serializer_class.Meta.read_only_fields = ('trusting', 'trusting',)
return serializer_class return serializer_class
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
@ -72,7 +83,37 @@ class AliasViewSet(ReadProtectedModelViewSet):
try: try:
self.perform_destroy(instance) self.perform_destroy(instance)
except ValidationError as e: except ValidationError as e:
return Response({e.code: e.message}, status.HTTP_400_BAD_REQUEST) 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/note/aliases/
"""
queryset = Alias.objects
serializer_class = AliasSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$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):
serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']:
# alias owner cannot be change once establish
serializer_class.Meta.read_only_fields = ('note',)
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) return Response(status=status.HTTP_204_NO_CONTENT)
def get_queryset(self): def get_queryset(self):
@ -104,11 +145,13 @@ class AliasViewSet(ReadProtectedModelViewSet):
class ConsumerViewSet(ReadOnlyProtectedModelViewSet): class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
queryset = Alias.objects.all() queryset = Alias.objects
serializer_class = ConsumerSerializer serializer_class = ConsumerSerializer
filter_backends = [SearchFilter, OrderingFilter] filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
ordering_fields = ['name', 'normalized_name'] 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): def get_queryset(self):
""" """
@ -116,27 +159,37 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
:return: The filtered set of requested aliases :return: The filtered set of requested aliases
""" """
queryset = super().get_queryset() queryset = super().get_queryset().distinct()
# Sqlite doesn't support ORDER BY in subqueries # Sqlite doesn't support ORDER BY in subqueries
queryset = queryset.order_by("name") \ queryset = queryset.order_by("name") \
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
alias = self.request.query_params.get("alias", ".*") alias = self.request.query_params.get("alias", None)
# Check if this is a valid regex. If not, we won't check regex
try:
re.compile(alias)
valid_regex = True
except (re.error, TypeError):
valid_regex = False
suffix = '__iregex' if valid_regex else '__istartswith'
alias_prefix = '^' if valid_regex else ''
queryset = queryset.prefetch_related('note') queryset = queryset.prefetch_related('note')
if alias:
# We match first an alias if it is matched without normalization, # We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias. # then if the normalized pattern matches a normalized alias.
queryset = queryset.filter( queryset = queryset.filter(
name__iregex="^" + alias **{f'name{suffix}': alias_prefix + alias}
).union( ).union(
queryset.filter( queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias)) Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(name__iregex="^" + alias) & ~Q(**{f'name{suffix}': alias_prefix + alias})
), ),
all=True).union( all=True).union(
queryset.filter( queryset.filter(
Q(normalized_name__iregex="^" + alias.lower()) Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias)) & ~Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(name__iregex="^" + alias) & ~Q(**{f'name{suffix}': alias_prefix + alias})
), ),
all=True) all=True)
@ -152,10 +205,11 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/category/ then render it on /api/note/transaction/category/
""" """
queryset = TemplateCategory.objects.order_by("name").all() queryset = TemplateCategory.objects.order_by('name')
serializer_class = TemplateCategorySerializer serializer_class = TemplateCategorySerializer
filter_backends = [SearchFilter] filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ['$name', ] filterset_fields = ['name', 'templates', 'templates__name']
search_fields = ['$name', '$templates__name', ]
class TransactionTemplateViewSet(viewsets.ModelViewSet): class TransactionTemplateViewSet(viewsets.ModelViewSet):
@ -164,11 +218,12 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/template/ then render it on /api/note/transaction/template/
""" """
queryset = TransactionTemplate.objects.order_by("name").all() queryset = TransactionTemplate.objects.order_by('name')
serializer_class = TransactionTemplateSerializer serializer_class = TransactionTemplateSerializer
filter_backends = [SearchFilter, DjangoFilterBackend] filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ['name', 'amount', 'display', 'category', ] filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ]
search_fields = ['$name', ] search_fields = ['$name', '$category__name', ]
ordering_fields = ['amount', ]
class TransactionViewSet(ReadProtectedModelViewSet): class TransactionViewSet(ReadProtectedModelViewSet):
@ -177,13 +232,18 @@ class TransactionViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,
then render it on /api/note/transaction/transaction/ then render it on /api/note/transaction/transaction/
""" """
queryset = Transaction.objects.order_by("-created_at").all() queryset = Transaction.objects.order_by('-created_at')
serializer_class = TransactionPolymorphicSerializer serializer_class = TransactionPolymorphicSerializer
filter_backends = [SearchFilter] filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$reason', ] filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name',
'destination', 'destination_alias', 'destination__alias__name',
'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount',
'created_at', 'valid', 'invalidity_reason', ]
search_fields = ['$reason', '$source_alias', '$source__alias__name', '$source__alias__normalized_name',
'$destination_alias', '$destination__alias__name', '$destination__alias__normalized_name',
'$invalidity_reason', ]
ordering_fields = ['created_at', 'amount', ]
def get_queryset(self): def get_queryset(self):
user = self.request.user return self.model.objects.filter(PermissionBackend.filter_queryset(self.request, self.model, "view"))\
get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\
.order_by("created_at", "id") .order_by("created_at", "id")

View File

@ -1,9 +1,9 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import pre_delete, pre_save, post_save
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from . import signals from . import signals
@ -17,6 +17,15 @@ class NoteConfig(AppConfig):
""" """
Define app internal signals to interact with other apps Define app internal signals to interact with other apps
""" """
pre_save.connect(
signals.pre_save_note,
sender="note.noteuser",
)
pre_save.connect(
signals.pre_save_note,
sender="note.noteclub",
)
post_save.connect( post_save.connect(
signals.save_user_note, signals.save_user_note,
sender=settings.AUTH_USER_MODEL, sender=settings.AUTH_USER_MODEL,

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime from datetime import datetime

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.19 on 2021-03-13 11:35
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0004_remove_null_tag_on_charfields'),
]
operations = [
migrations.AlterField(
model_name='alias',
name='note',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alias', to='note.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': 'frienship',
'verbose_name_plural': 'friendships',
'unique_together': {('trusting', 'trusted')},
},
),
]

View File

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

View File

@ -1,14 +1,13 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import unicodedata import unicodedata
from django.conf import settings from django.conf import settings
from django.conf.global_settings import DEFAULT_FROM_EMAIL
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import send_mail from django.core.mail import send_mail
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models, transaction
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -93,6 +92,7 @@ class Note(PolymorphicModel):
delta = timezone.now() - self.last_negative delta = timezone.now() - self.last_negative
return "{:d} jours".format(delta.days) return "{:d} jours".format(delta.days)
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
Save note with it's alias (called in polymorphic children) Save note with it's alias (called in polymorphic children)
@ -108,12 +108,16 @@ class Note(PolymorphicModel):
# Save alias # Save alias
a.note = self a.note = self
# Consider that if the name of the note could be changed, then the alias can be created.
# It does not mean that any alias can be created.
a._force_save = True
a.save(force_insert=True) a.save(force_insert=True)
else: else:
# Check if the name of the note changed without changing the normalized form of the alias # Check if the name of the note changed without changing the normalized form of the alias
alias = Alias.objects.get(normalized_name=Alias.normalize(str(self))) alias = Alias.objects.get(normalized_name=Alias.normalize(str(self)))
if alias.name != str(self): if alias.name != str(self):
alias.name = str(self) alias.name = str(self)
alias._force_save = True
alias.save() alias.save()
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
@ -154,19 +158,6 @@ class NoteUser(Note):
def pretty(self): def pretty(self):
return _("%(user)s's note") % {'user': str(self.user)} return _("%(user)s's note") % {'user': str(self.user)}
def save(self, *args, **kwargs):
if self.pk and self.balance < 0:
old_note = NoteUser.objects.get(pk=self.pk)
super().save(*args, **kwargs)
if old_note.balance >= 0:
# Passage en négatif
self.last_negative = timezone.now()
self._force_save = True
self.save(*args, **kwargs)
self.send_mail_negative_balance()
else:
super().save(*args, **kwargs)
def send_mail_negative_balance(self): def send_mail_negative_balance(self):
plain_text = render_to_string("note/mails/negative_balance.txt", dict(note=self)) plain_text = render_to_string("note/mails/negative_balance.txt", dict(note=self))
html = render_to_string("note/mails/negative_balance.html", dict(note=self)) html = render_to_string("note/mails/negative_balance.html", dict(note=self))
@ -195,24 +186,11 @@ class NoteClub(Note):
def pretty(self): def pretty(self):
return _("Note of %(club)s club") % {'club': str(self.club)} return _("Note of %(club)s club") % {'club': str(self.club)}
def save(self, *args, **kwargs):
if self.pk and self.balance < 0:
old_note = NoteClub.objects.get(pk=self.pk)
super().save(*args, **kwargs)
if old_note.balance >= 0:
# Passage en négatif
self.last_negative = timezone.now()
self._force_save = True
self.save(*args, **kwargs)
self.send_mail_negative_balance()
else:
super().save(*args, **kwargs)
def send_mail_negative_balance(self): def send_mail_negative_balance(self):
plain_text = render_to_string("note/mails/negative_balance.txt", dict(note=self)) plain_text = render_to_string("note/mails/negative_balance.txt", dict(note=self))
html = render_to_string("note/mails/negative_balance.html", dict(note=self)) html = render_to_string("note/mails/negative_balance.html", dict(note=self))
send_mail("[Note Kfet] Passage en négatif (club {})".format(self.club.name), plain_text, DEFAULT_FROM_EMAIL, send_mail("[Note Kfet] Passage en négatif (club {})".format(self.club.name), plain_text,
[self.club.email], html_message=html) settings.DEFAULT_FROM_EMAIL, [self.club.email], html_message=html)
class NoteSpecial(Note): class NoteSpecial(Note):
@ -239,6 +217,38 @@ class NoteSpecial(Note):
return self.special_type return self.special_type
class Trust(models.Model):
"""
A one-sided trust relationship bertween two users
If another user considers you as your friend, you can transfer money from
them
"""
trusting = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='trusting',
verbose_name=_('trusting')
)
trusted = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='trusted',
verbose_name=_('trusted')
)
class Meta:
verbose_name = _("frienship")
verbose_name_plural = _("friendships")
unique_together = ("trusting", "trusted")
def __str__(self):
return _("Friendship between {trusting} and {trusted}").format(
trusting=str(self.trusting), trusted=str(self.trusted))
class Alias(models.Model): class Alias(models.Model):
""" """
points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance. points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance.
@ -269,6 +279,7 @@ class Alias(models.Model):
note = models.ForeignKey( note = models.ForeignKey(
Note, Note,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="alias",
) )
class Meta: class Meta:
@ -310,6 +321,7 @@ class Alias(models.Model):
pass pass
self.normalized_name = normalized_name self.normalized_name = normalized_name
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.clean() self.clean()
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -170,15 +170,17 @@ class Transaction(PolymorphicModel):
previous_source_balance = self.source.balance previous_source_balance = self.source.balance
previous_dest_balance = self.destination.balance previous_dest_balance = self.destination.balance
source_balance = self.source.balance source_balance = previous_source_balance
dest_balance = self.destination.balance dest_balance = previous_dest_balance
created = self.pk is None created = self.pk is None
to_transfer = self.amount * self.quantity to_transfer = self.total
if not created and not self.valid and not hasattr(self, "_force_save"): if not created:
# Revert old transaction # Revert old transaction
old_transaction = Transaction.objects.get(pk=self.pk) # We make a select for update to avoid concurrency issues
old_transaction = Transaction.objects.select_for_update().get(pk=self.pk)
# Check that nothing important changed # Check that nothing important changed
if not hasattr(self, "_force_save"):
for field_name in ["source_id", "destination_id", "quantity", "amount"]: for field_name in ["source_id", "destination_id", "quantity", "amount"]:
if getattr(self, field_name) != getattr(old_transaction, field_name): if getattr(self, field_name) != getattr(old_transaction, field_name):
raise ValidationError(_("You can't update the {field} on a Transaction. " raise ValidationError(_("You can't update the {field} on a Transaction. "
@ -215,14 +217,14 @@ class Transaction(PolymorphicModel):
# When source == destination, no money is transferred and no transaction is created # When source == destination, no money is transferred and no transaction is created
return return
# We refresh the notes with the "select for update" tag to avoid concurrency issues self.source = Note.objects.select_for_update().get(pk=self.source_id)
self.source = Note.objects.filter(pk=self.source_id).select_for_update().get() self.destination = Note.objects.select_for_update().get(pk=self.destination_id)
self.destination = Note.objects.filter(pk=self.destination_id).select_for_update().get()
# Check that the amounts stay between big integer bounds # Check that the amounts stay between big integer bounds
diff_source, diff_dest = self.validate() diff_source, diff_dest = self.validate()
if not self.source.is_active or not self.destination.is_active: if not (hasattr(self, '_force_save') and self._force_save) \
and (not self.source.is_active or not self.destination.is_active):
raise ValidationError(_("The transaction can't be saved since the source note " raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active.")) "or the destination note is not active."))
@ -237,9 +239,11 @@ class Transaction(PolymorphicModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Save notes # Save notes
self.source.refresh_from_db()
self.source.balance += diff_source self.source.balance += diff_source
self.source._force_save = True self.source._force_save = True
self.source.save() self.source.save()
self.destination.refresh_from_db()
self.destination.balance += diff_dest self.destination.balance += diff_dest
self.destination._force_save = True self.destination._force_save = True
self.destination.save() self.destination.save()
@ -268,11 +272,12 @@ class RecurrentTransaction(Transaction):
) )
def clean(self): def clean(self):
if self.template.destination != self.destination: if self.template.destination != self.destination and not (hasattr(self, '_force_save') and self._force_save):
raise ValidationError( raise ValidationError(
_("The destination of this transaction must equal to the destination of the template.")) _("The destination of this transaction must equal to the destination of the template."))
return super().clean() return super().clean()
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.clean() self.clean()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@ -323,10 +328,41 @@ class SpecialTransaction(Transaction):
raise(ValidationError(_("A special transaction is only possible between a" raise(ValidationError(_("A special transaction is only possible between a"
" Note associated to a payment method and a User or a Club"))) " Note associated to a payment method and a User or a Club")))
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.clean() self.clean()
super().save(*args, **kwargs) super().save(*args, **kwargs)
@staticmethod
def validate_payment_form(form):
"""
Ensure that last name and first name are filled for a form that creates a SpecialTransaction,
and check that if the user pays with a check, then the bank field is filled.
Return True iff there is no error.
Whenever there is an error, they are inserted in the form errors.
"""
credit_type = form.cleaned_data["credit_type"]
last_name = form.cleaned_data["last_name"]
first_name = form.cleaned_data["first_name"]
bank = form.cleaned_data["bank"]
error = False
if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"):
if not last_name:
form.add_error('last_name', _("This field is required."))
error = True
if not first_name:
form.add_error('first_name', _("This field is required."))
error = True
if not bank and credit_type.special_type == "Chèque":
form.add_error('bank', _("This field is required."))
error = True
return not error
class Meta: class Meta:
verbose_name = _("Special transaction") verbose_name = _("Special transaction")
verbose_name_plural = _("Special transactions") verbose_name_plural = _("Special transactions")

View File

@ -1,6 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.utils import timezone
def save_user_note(instance, raw, **_kwargs): def save_user_note(instance, raw, **_kwargs):
""" """
@ -25,10 +27,21 @@ def save_club_note(instance, raw, **_kwargs):
instance.note.save() instance.note.save()
def pre_save_note(instance, raw, **_kwargs):
if not raw and instance.pk and not hasattr(instance, "_no_signal") and instance.balance < 0:
from note.models import Note
old_note = Note.objects.get(pk=instance.pk)
if old_note.balance >= 0:
# Passage en négatif
instance.last_negative = timezone.now()
instance.send_mail_negative_balance()
def delete_transaction(instance, **_kwargs): def delete_transaction(instance, **_kwargs):
""" """
Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first. Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first.
""" """
if not hasattr(instance, "_no_signal"): if not hasattr(instance, "_no_signal"):
instance.valid = False instance.valid = False
instance._force_save = True
instance.save() instance.save()

View File

@ -1,4 +1,4 @@
// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay // Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// When a transaction is performed, lock the interface to prevent spam clicks. // When a transaction is performed, lock the interface to prevent spam clicks.
@ -28,8 +28,7 @@ $(document).ready(function () {
// Switching in double consumptions mode should update the layout // Switching in double consumptions mode should update the layout
$('#double_conso').change(function () { $('#double_conso').change(function () {
$('#consos_list_div').removeClass('d-none') document.getElementById('consos_list_div').classList.remove('d-none')
$('#user_select_div').attr('class', 'col-xl-4')
$('#infos_div').attr('class', 'col-sm-5 col-xl-6') $('#infos_div').attr('class', 'col-sm-5 col-xl-6')
const note_list_obj = $('#note_list') const note_list_obj = $('#note_list')
@ -38,7 +37,7 @@ $(document).ready(function () {
note_list_obj.html('') note_list_obj.html('')
buttons.forEach(function (button) { buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () { document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => {
if (LOCK) { return } if (LOCK) { return }
removeNote(button, 'conso_button', buttons, 'consos_list')() removeNote(button, 'conso_button', buttons, 'consos_list')()
}) })
@ -47,8 +46,7 @@ $(document).ready(function () {
}) })
$('#single_conso').change(function () { $('#single_conso').change(function () {
$('#consos_list_div').addClass('d-none') document.getElementById('consos_list_div').classList.add('d-none')
$('#user_select_div').attr('class', 'col-xl-7')
$('#infos_div').attr('class', 'col-sm-5 col-md-4') $('#infos_div').attr('class', 'col-sm-5 col-md-4')
const consos_list_obj = $('#consos_list') const consos_list_obj = $('#consos_list')
@ -70,9 +68,9 @@ $(document).ready(function () {
}) })
// Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS // Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS
$("label[for='double_conso']").removeClass('active') document.querySelector("label[for='double_conso']").classList.remove('active')
$('#consume_all').click(consumeAll) document.getElementById("consume_all").addEventListener('click', consumeAll)
}) })
notes = [] notes = []
@ -129,11 +127,10 @@ function addConso (dest, amount, type, category_id, category_name, template_id,
html += li('conso_button_' + button.id, button.name + html += li('conso_button_' + button.id, button.name +
'<span class="badge badge-dark badge-pill">' + button.quantity + '</span>') '<span class="badge badge-dark badge-pill">' + button.quantity + '</span>')
}) })
document.getElementById(list).innerHTML = html
$('#' + list).html(html) buttons.forEach((button) => {
document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => {
buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () {
if (LOCK) { return } if (LOCK) { return }
removeNote(button, 'conso_button', buttons, list)() removeNote(button, 'conso_button', buttons, list)()
}) })
@ -148,12 +145,13 @@ function reset () {
notes_display.length = 0 notes_display.length = 0
notes.length = 0 notes.length = 0
buttons.length = 0 buttons.length = 0
$('#note_list').html('') document.getElementById('note_list').innerHTML = ''
$('#consos_list').html('') document.getElementById('consos_list').innerHTML = ''
$('#note').val('') document.getElementById('note').value = ''
$('#note').attr('data-original-title', '').tooltip('hide') document.getElementById('note').dataset.originTitle = ''
$('#profile_pic').attr('src', '/static/member/img/default_picture.png') $('#note').tooltip('hide')
$('#profile_pic_link').attr('href', '#') document.getElementById('profile_pic').src = '/static/member/img/default_picture.png'
document.getElementById('profile_pic_link').href = '#'
refreshHistory() refreshHistory()
refreshBalance() refreshBalance()
LOCK = false LOCK = false
@ -170,7 +168,7 @@ function consumeAll () {
let error = false let error = false
if (notes_display.length === 0) { if (notes_display.length === 0) {
$('#note').addClass('is-invalid') document.getElementById('note').classList.add('is-invalid')
$('#note_list').html(li('', '<strong>Ajoutez des émetteurs.</strong>', 'text-danger')) $('#note_list').html(li('', '<strong>Ajoutez des émetteurs.</strong>', 'text-danger'))
error = true error = true
} }
@ -223,17 +221,15 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
.done(function () { .done(function () {
if (!isNaN(source.balance)) { if (!isNaN(source.balance)) {
const newBalance = source.balance - quantity * amount const newBalance = source.balance - quantity * amount
if (newBalance <= -5000) { if (newBalance <= -2000) {
addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' + addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'succès, mais la note émettrice ' + source_alias + ' est en négatif sévère.', 'but the emitter note %s is very negative.'), [source_alias, source_alias]), 'danger', 30000)
'danger', 30000)
} else if (newBalance < 0) { } else if (newBalance < 0) {
addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' + addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'succès, mais la note émettrice ' + source_alias + ' est en négatif.', 'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000)
'warning', 30000)
} }
if (source.membership && source.membership.date_end < new Date().toISOString()) { if (source.membership && source.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.", addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]),
'danger', 30000) 'danger', 30000)
} }
} }
@ -255,7 +251,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
template: template template: template
}).done(function () { }).done(function () {
reset() reset()
addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", 'danger', 10000) addMsg(gettext("The transaction couldn't be validated because of insufficient balance."), 'danger', 10000)
}).fail(function () { }).fail(function () {
reset() reset()
errMsg(e.responseJSON) errMsg(e.responseJSON)

View File

@ -67,7 +67,11 @@ $(document).ready(function () {
last.quantity = 1 last.quantity = 1
if (!last.note.user) { if (last.note.club) {
$('#last_name').val(last.note.name)
$('#first_name').val(last.note.name)
}
else if (!last.note.user) {
$.getJSON('/api/note/note/' + last.note.id + '/?format=json', function (note) { $.getJSON('/api/note/note/' + last.note.id + '/?format=json', function (note) {
last.note.user = note.user last.note.user = note.user
$.getJSON('/api/user/' + last.note.user + '/', function (user) { $.getJSON('/api/user/' + last.note.user + '/', function (user) {
@ -218,6 +222,13 @@ $(document).ready(function () {
}) })
}) })
// Make transfer when pressing Enter on the amount section
$('#amount, #reason, #last_name, #first_name, #bank').keypress((event) => {
if (event.originalEvent.charCode === 13) {
$('#btn_transfer').click()
}
})
$('#btn_transfer').click(function () { $('#btn_transfer').click(function () {
if (LOCK) { return } if (LOCK) { return }
@ -235,20 +246,20 @@ $('#btn_transfer').click(function () {
if (!amount_field.val() || isNaN(amount_field.val()) || amount_field.val() <= 0) { if (!amount_field.val() || isNaN(amount_field.val()) || amount_field.val() <= 0) {
amount_field.addClass('is-invalid') amount_field.addClass('is-invalid')
$('#amount-required').html('<strong>Ce champ est requis et doit comporter un nombre décimal strictement positif.</strong>') $('#amount-required').html('<strong>' + gettext('This field is required and must contain a decimal positive number.') + '</strong>')
error = true error = true
} }
const amount = Math.floor(100 * amount_field.val()) const amount = Math.round(100 * amount_field.val())
if (amount > 2147483647) { if (amount > 2147483647) {
amount_field.addClass('is-invalid') amount_field.addClass('is-invalid')
$('#amount-required').html('<strong>Le montant ne doit pas excéder 21474836.47 €.</strong>') $('#amount-required').html('<strong>' + gettext('The amount must stay under 21,474,836.47 €.') + '</strong>')
error = true error = true
} }
if (!reason_field.val()) { if (!reason_field.val() && $('#type_transfer').is(':checked')) {
reason_field.addClass('is-invalid') reason_field.addClass('is-invalid')
$('#reason-required').html('<strong>Ce champ est requis.</strong>') $('#reason-required').html('<strong>' + gettext('This field is required.') + '</strong>')
error = true error = true
} }
@ -274,9 +285,8 @@ $('#btn_transfer').click(function () {
[...sources_notes_display].forEach(function (source) { [...sources_notes_display].forEach(function (source) {
[...dests_notes_display].forEach(function (dest) { [...dests_notes_display].forEach(function (dest) {
if (source.note.id === dest.note.id) { if (source.note.id === dest.note.id) {
addMsg('Attention : la transaction de ' + pretty_money(amount) + ' de la note ' + source.name + addMsg(interpolate(gettext('Warning: the transaction of %s from %s to %s was not made because ' +
' vers la note ' + dest.name + " n'a pas été faite car il s'agit de la même note au départ" + 'it is the same source and destination note.'), [pretty_money(amount), source.name, dest.name]), 'warning', 10000)
" et à l'arrivée.", 'warning', 10000)
LOCK = false LOCK = false
return return
} }
@ -296,43 +306,35 @@ $('#btn_transfer').click(function () {
destination_alias: dest.name destination_alias: dest.name
}).done(function () { }).done(function () {
if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) { if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.", addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source.name]), 'danger', 30000)
'danger', 30000)
} }
if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) { if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note destination ' + dest.name + " n'est plus adhérente.", addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000)
'danger', 30000)
} }
if (!isNaN(source.note.balance)) { if (!isNaN(source.note.balance)) {
const newBalance = source.note.balance - source.quantity * dest.quantity * amount const newBalance = source.note.balance - source.quantity * dest.quantity * amount
if (newBalance <= -5000) { if (newBalance <= -2000) {
addMsg('Le transfert de ' + addMsg(interpolate(gettext('Warning, the transaction of %s from the note %s to the note %s succeed, but the emitter note %s is very negative.'),
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, source.name]), 'danger', 10000)
source.name + ' vers la note ' + dest.name + ' a été fait avec succès, ' +
'mais la note émettrice est en négatif sévère.', 'danger', 10000)
reset() reset()
return return
} else if (newBalance < 0) { } else if (newBalance < 0) {
addMsg('Le transfert de ' + addMsg(interpolate(gettext('Warning, the transaction of %s from the note %s to the note %s succeed, but the emitter note %s is negative.'),
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, source.name]), 'danger', 10000)
source.name + ' vers la note ' + dest.name + ' a été fait avec succès, ' +
'mais la note émettrice est en négatif.', 'warning', 10000)
reset() reset()
return return
} }
} }
addMsg('Le transfert de ' + addMsg(interpolate(gettext('Transfer of %s from %s to %s succeed!'),
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name + [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name]), 'success', 10000)
' vers la note ' + dest.name + ' a été fait avec succès !', 'success', 10000)
reset() reset()
}).fail(function (err) { // do it again but valid = false }).fail(function (err) { // do it again but valid = false
const errObj = JSON.parse(err.responseText) const errObj = JSON.parse(err.responseText)
if (errObj.non_field_errors) { if (errObj.non_field_errors) {
addMsg('Le transfert de ' + addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name + [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, errObj.non_field_errors]), 'danger')
' vers la note ' + dest.name + ' a échoué : ' + errObj.non_field_errors, 'danger')
LOCK = false LOCK = false
return return
} }
@ -352,17 +354,15 @@ $('#btn_transfer').click(function () {
destination: dest.note.id, destination: dest.note.id,
destination_alias: dest.name destination_alias: dest.name
}).done(function () { }).done(function () {
addMsg('Le transfert de ' + addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name + [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, gettext('insufficient funds')]), 'danger', 10000)
' vers la note ' + dest.name + ' a échoué : Solde insuffisant', 'danger', 10000)
reset() reset()
}).fail(function (err) { }).fail(function (err) {
const errObj = JSON.parse(err.responseText) const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText } if (!error) { error = err.responseText }
addMsg('Le transfert de ' + addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name + [pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, error]), 'danger')
' vers la note ' + dest.name + ' a échoué : ' + error, 'danger')
LOCK = false LOCK = false
}) })
}) })
@ -388,7 +388,7 @@ $('#btn_transfer').click(function () {
alias = sources_notes_display[0].name alias = sources_notes_display[0].name
source_id = user_note.id source_id = user_note.id
dest_id = special_note dest_id = special_note
reason = 'Retrait ' + $('#credit_type option:selected').text().toLowerCase() reason = 'Retrait ' + $('#debit_type option:selected').text().toLowerCase()
if (given_reason.length > 0) { reason += ' (' + given_reason + ')' } if (given_reason.length > 0) { reason += ' (' + given_reason + ')' }
} }
$.post('/api/note/transaction/transaction/', $.post('/api/note/transaction/transaction/',
@ -408,14 +408,14 @@ $('#btn_transfer').click(function () {
first_name: $('#first_name').val(), first_name: $('#first_name').val(),
bank: $('#bank').val() bank: $('#bank').val()
}).done(function () { }).done(function () {
addMsg('Le crédit/retrait a bien été effectué !', 'success', 10000) addMsg(gettext('Credit/debit succeed!'), 'success', 10000)
if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg('Attention : la note ' + alias + " n'est plus adhérente.", 'danger', 10000) } if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) }
reset() reset()
}).fail(function (err) { }).fail(function (err) {
const errObj = JSON.parse(err.responseText) const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText } if (!error) { error = err.responseText }
addMsg('Le crédit/retrait a échoué : ' + error, 'danger', 10000) addMsg(interpolate(gettext('Credit/debit failed: %s'), [error]), 'danger', 10000)
LOCK = false LOCK = false
}) })
} }

View File

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

View File

@ -10,22 +10,22 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-sm-5 col-md-4" id="infos_div"> <div class="col-sm-5 col-md-4" id="infos_div">
<div class="row"> <div class="row justify-content-center justify-content-md-end">
{# User details column #} {# User details column #}
<div class="col"> <div class="col picture-col">
<div class="card bg-light border-success mb-4 text-center"> <div class="card bg-light mb-4 text-center">
<a id="profile_pic_link" href="#"> <a id="profile_pic_link" href="#">
<img src="{% static "member/img/default_picture.png" %}" <img src="{% static "member/img/default_picture.png" %}"
id="profile_pic" alt="" class="card-img-top"> id="profile_pic" alt="" class="card-img-top d-none d-sm-block">
</a> </a>
<div class="card-body text-center text-break"> <div class="card-body text-center text-break p-2">
<span id="user_note"></span> <span id="user_note"><i class="small">{% trans "Please select a note" %}</i></span>
</div> </div>
</div> </div>
</div> </div>
{# User selection column #} {# User selection column #}
<div class="col-xl-7" id="user_select_div"> <div class="col-xl" id="user_select_div">
<div class="card bg-light border-success mb-4"> <div class="card bg-light border-success mb-4">
<div class="card-header"> <div class="card-header">
<p class="card-text font-weight-bold"> <p class="card-text font-weight-bold">
@ -44,6 +44,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
</div> </div>
{# Summary of consumption and consume button #} {# Summary of consumption and consume button #}
<div class="col-xl-5 d-none" id="consos_list_div"> <div class="col-xl-5 d-none" id="consos_list_div">
<div class="card bg-light border-info mb-4"> <div class="card bg-light border-info mb-4">
@ -65,7 +66,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
{# Show last used buttons #} {# Show last used buttons #}
<div class="card bg-light mb-4"> <div class="card bg-light mb-4">
<div class="card-header"> <div class="card-header">
@ -159,7 +159,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}
<script type="text/javascript" src="{% static "js/consos.js" %}"></script> <script type="text/javascript" src="{% static "note/js/consos.js" %}"></script>
<script type="text/javascript"> <script type="text/javascript">
{% for button in highlighted %} {% for button in highlighted %}
{% if button.display %} {% if button.display %}

View File

@ -10,6 +10,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
{# bandeau transfert/crédit/débit/activité #} {# bandeau transfert/crédit/débit/activité #}
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div class="btn-group btn-block">
<div class="btn-group btn-group-toggle btn-block" data-toggle="buttons"> <div class="btn-group btn-group-toggle btn-block" data-toggle="buttons">
<label for="type_transfer" class="btn btn-sm btn-outline-primary active"> <label for="type_transfer" class="btn btn-sm btn-outline-primary active">
<input type="radio" name="transaction_type" id="type_transfer"> <input type="radio" name="transaction_type" id="type_transfer">
@ -25,6 +26,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
{% trans "Debit" %} {% trans "Debit" %}
</label> </label>
{% endif %} {% endif %}
</div>
{# Add shortcuts for opened activites if necessary #}
{% for activity in activities_open %} {% for activity in activities_open %}
<a href="{% url "activity:activity_entry" pk=activity.pk %}" class="btn btn-sm btn-outline-primary"> <a href="{% url "activity:activity_entry" pk=activity.pk %}" class="btn btn-sm btn-outline-primary">
{% trans "Entries" %} {{ activity.name }} {% trans "Entries" %} {{ activity.name }}
@ -34,21 +38,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<hr> <hr>
<div class="row"> <div class="row justify-content-center">
{# Preview note profile (picture, username and balance) #} {# Preview note profile (picture, username and balance) #}
<div class="col-md-3" id="note_infos_div"> <div class="col-md picture-col" id="note_infos_div">
<div class="card bg-light border-success shadow mb-4 pt-4 text-center"> <div class="card bg-light mb-4 text-center">
<a id="profile_pic_link" href="#"><img src="{% static "member/img/default_picture.png" %}" <a id="profile_pic_link" href="#"><img src="{% static "member/img/default_picture.png" %}"
id="profile_pic" alt="" class="img-fluid rounded mx-auto"></a> id="profile_pic" alt="" class="img-fluid rounded mx-auto"></a>
<div class="card-body text-center"> <div class="card-body text-center p-2">
<span id="user_note"></span> <span id="user_note"><i class="small">{% trans "Please select a note" %}</i></span>
</div> </div>
</div> </div>
</div> </div>
{# list of emitters #} {# list of emitters #}
<div class="col-md-3" id="emitters_div"> <div class="col-md-3" id="emitters_div">
<div class="card bg-light border-success shadow mb-4"> <div class="card bg-light mb-4">
<div class="card-header"> <div class="card-header">
<p class="card-text font-weight-bold"> <p class="card-text font-weight-bold">
<label for="source_note" id="source_note_label">{% trans "Select emitters" %}</label> <label for="source_note" id="source_note_label">{% trans "Select emitters" %}</label>
@ -57,7 +61,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ul class="list-group list-group-flush" id="source_note_list"> <ul class="list-group list-group-flush" id="source_note_list">
</ul> </ul>
<div class="card-body"> <div class="card-body">
<select id="credit_type" class="custom-select d-none"> <select id="credit_type" class="form-control custom-select d-none">
{% for special_type in special_types %} {% for special_type in special_types %}
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option> <option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %} {% endfor %}
@ -75,7 +79,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
{# list of receiver #} {# list of receiver #}
<div class="col-md-3" id="dests_div"> <div class="col-md-3" id="dests_div">
<div class="card bg-light border-info shadow mb-4"> <div class="card bg-light mb-4">
<div class="card-header"> <div class="card-header">
<p class="card-text font-weight-bold" id="dest_title"> <p class="card-text font-weight-bold" id="dest_title">
<label for="dest_note" id="dest_note_label">{% trans "Select receivers" %}</label> <label for="dest_note" id="dest_note_label">{% trans "Select receivers" %}</label>
@ -84,7 +88,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ul class="list-group list-group-flush" id="dest_note_list"> <ul class="list-group list-group-flush" id="dest_note_list">
</ul> </ul>
<div class="card-body"> <div class="card-body">
<select id="debit_type" class="custom-select d-none"> <select id="debit_type" class="form-control custom-select d-none">
{% for special_type in special_types %} {% for special_type in special_types %}
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option> <option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %} {% endfor %}
@ -97,8 +101,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
{# Information on transaction (amount, reason, name,...) #} {# Information on transaction (amount, reason, name,...) #}
<div class="col-md-3" id="external_div"> <div class="col-md" id="external_div">
<div class="card bg-light border-warning shadow mb-4"> <div class="card bg-light mb-4">
<div class="card-header"> <div class="card-header">
<p class="card-text font-weight-bold"> <p class="card-text font-weight-bold">
{% trans "Action" %} {% trans "Action" %}
@ -153,7 +157,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
{# transaction history #} {# transaction history #}
<div class="card shadow mb-4" id="history"> <div class="card mb-4" id="history">
<div class="card-header"> <div class="card-header">
<p class="card-text font-weight-bold"> <p class="card-text font-weight-bold">
{% trans "Recent transactions history" %} {% trans "Recent transactions history" %}
@ -176,5 +180,5 @@ SPDX-License-Identifier: GPL-2.0-or-later
select_receveirs_label = "{% trans "Select receivers" %}"; select_receveirs_label = "{% trans "Select receivers" %}";
transfer_type_label = "{% trans "Transfer type" %}"; transfer_type_label = "{% trans "Transfer type" %}";
</script> </script>
<script src="/static/js/transfer.js"></script> <script src="{% static "note/js/transfer.js" %}"></script>
{% endblock %} {% endblock %}

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django import template from django import template

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django import template from django import template

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