Compare commits

...

568 Commits

Author SHA1 Message Date
ynerant b655135a42 Merge branch 'beta' into 'master'
PC Kfet

See merge request bde/nk20!136
2020-10-20 10:43:01 +02:00
Yohann D'ANELLO 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
Yohann D'ANELLO 6cc3cf4174 A migration put the right role in the note account's memberships 2020-10-20 00:28:49 +02:00
Yohann D'ANELLO 2097e67321 Add permissions to PC Kfet 2020-10-20 00:19:49 +02:00
Yohann D'ANELLO d773303d18 Add possibility to authenticate an account with its IP address 2020-10-19 23:44:56 +02:00
ynerant 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
Yohann D'ANELLO bf29efda0a Display real user name in the Soge credits list/detail 2020-10-08 10:36:30 +02:00
ynerant 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
Yohann D'ANELLO 3eced33082 Well, everyone doesn't want a secondary bank account 2020-10-07 17:43:28 +02:00
Yohann D'ANELLO acb3fb4a91 Highlight future users that declared that they opened a bank account 2020-10-07 17:42:46 +02:00
ynerant 1c5e951c2f Merge branch 'beta' into 'master'
Various fixes

See merge request bde/nk20!133
2020-10-07 12:06:48 +02:00
Yohann D'ANELLO 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
Yohann D'ANELLO 0078eb8f90 Index page is a redirection 2020-10-07 11:53:42 +02:00
Yohann D'ANELLO e5e758f9d9 Display banners when a user is no more a BDE or Kfet member 2020-10-07 11:46:43 +02:00
Yohann D'ANELLO 4a78328717 The checkbox to tell that a Sogé account got opened is not mandatory 2020-10-07 11:31:20 +02:00
Yohann D'ANELLO 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
Yohann D'ANELLO b5fa428bad Non-Kfet members can see their old aliases only, but no one else 2020-10-07 11:22:02 +02:00
Yohann D'ANELLO fb72385773 Warn users that they have to open they Sogé account 2020-10-07 10:59:37 +02:00
Yohann D'ANELLO 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
Yohann D'ANELLO 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
Yohann D'ANELLO 8ada0e51f2 The validation filter of the soge credit list was buggy 2020-10-07 10:42:52 +02:00
Yohann D'ANELLO c3d613947f Pre-registered users can declare that they opened a bank account in the signup form 2020-10-07 10:33:57 +02:00
Yohann D'ANELLO 36b8157372 Fix membership table order 2020-10-07 10:03:43 +02:00
Yohann D'ANELLO 992cfe8e23 Can set a parent club to None 2020-10-07 09:48:21 +02:00
Yohann D'ANELLO 18a8ff1b8a Set credit/debit reason non mandatory 2020-10-07 09:45:09 +02:00
Yohann D'ANELLO 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
Yohann D'ANELLO 4b12e3ed08 Display only the most recent membership 2020-10-07 09:29:41 +02:00
erdnaxe 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
erdnaxe bbe53b3b63 Update README.md 2020-10-05 16:25:06 +02:00
ynerant 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
Yohann D'ANELLO 541ed59f40 When a membership is created, redirect to the user profile page rather than club detail 2020-10-04 21:08:35 +02:00
Yohann D'ANELLO e172b4f4bb When a membership is renewed, set the same roles as the previous membership 2020-10-04 20:54:03 +02:00
Yohann D'ANELLO d666179037 Display Renew membership button 15 days more 2020-10-04 20:50:10 +02:00
Yohann D'ANELLO f22e92132c Select for update transaction notes, and not only the transaction 2020-10-04 20:47:15 +02:00
Yohann D'ANELLO ca7ad05746 Use a signal to prevent a user that the note balance is negative 2020-10-04 20:26:43 +02:00
Pierre-antoine Comby 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
Pierre-antoine Comby d4e4ed580f remove the display limit for pre-registred users. 2020-10-03 17:53:38 +02:00
ynerant 8756751344 Merge branch 'beta' into 'master'
Fix some membership issues

See merge request bde/nk20!129
2020-10-01 15:00:36 +02:00
Yohann D'ANELLO fd83fe19bf Fix some membership date control 2020-10-01 09:17:02 +02:00
Yohann D'ANELLO 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
ynerant 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
Yohann D'ANELLO e48ef92137 Revert commit that broke beta branch 2020-09-23 18:32:09 +02:00
erdnaxe 919d0b7e85 Merge branch 'ics_cache' into 'beta'
Ics cache

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

See merge request bde/nk20!126
2020-09-21 12:53:03 +02:00
Alexandre Iooss 4f188ca3e5 Admin is autodiscovering partially 2020-09-21 12:34:34 +02:00
Alexandre Iooss 72bac75fbd Add Django OAuth toolkit admin 2020-09-21 12:15:40 +02:00
Alexandre Iooss 6d54aae614
Fix django-oauth-toolkit version 2020-09-21 11:15:00 +02:00
Alexandre Iooss 8052152ea5
Add OAuth2 endpoints 2020-09-21 11:03:07 +02:00
Alexandre Iooss 70448db8e5
Remove Django CAS server and add oauth toolkit 2020-09-21 10:31:42 +02:00
ynerant 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
Yohann D'ANELLO 3ba61385a3 Debit is not credit 2020-09-20 11:12:44 +02:00
Yohann D'ANELLO 7353348d7a Rollback transaction when checking an add permission (experimental) 2020-09-20 09:07:51 +02:00
Yohann D'ANELLO f63e2e088e Don't log when the permission to lock a note is checked 2020-09-20 08:56:42 +02:00
elkmaennchen d566def706 Try to translate js, not working... 2020-09-19 22:03:45 +02:00
Yohann D'ANELLO eaf6769e8b Treasurers can make transactions with people that are no longer a member 2020-09-19 16:33:52 +02:00
Yohann D'ANELLO a61ec81cff note.crans.org is the default domain 2020-09-19 16:03:32 +02:00
Yohann D'ANELLO 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
Yohann D'ANELLO bcd96b2ed8 The BDE membership and the club membership must now be in two parts 2020-09-18 12:35:36 +02:00
ynerant 5c702187e5 Merge branch 'beta' into 'master'
Corrections diverses

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

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

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

See merge request bde/nk20!120
2020-09-12 09:19:04 +02:00
Yohann D'ANELLO 9b090a145c All transactions are now atomic 2020-09-11 22:52:16 +02:00
Yohann D'ANELLO 860c7b50e5 Filter a consumer by its note id 2020-09-10 14:42:52 +02:00
Yohann D'ANELLO afdc75c0bd Access to consumer object wa buggy 2020-09-10 14:41:09 +02:00
Yohann D'ANELLO c6603e8aa7 Add more filters in the API 2020-09-10 14:37:11 +02:00
Yohann D'ANELLO 72cc1638e6 Authenticate correctly users that connect with an authorization token 2020-09-10 09:31:27 +02:00
Yohann D'ANELLO 6a0dc4cb10 Users can see every API page since querysets are filtered and modifications are protected 2020-09-09 22:27:07 +02:00
Alexandre Iooss 0f1f3b9560 Do not serve static files outside of debug server 2020-09-09 17:14:03 +02:00
Alexandre Iooss c720e5483e Move transfer.js where it belongs 2020-09-09 16:45:15 +02:00
Alexandre Iooss 0fd3e9db78 Move consos.js where it belongs 2020-09-09 16:42:45 +02:00
erdnaxe 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
Alexandre Iooss ce4c22a4a1 Smaller text and larger padding on note label 2020-09-09 15:03:34 +02:00
Alexandre Iooss 3e0f665ef8 Resync es translation 2020-09-09 14:32:01 +02:00
Alexandre Iooss be8751c815 Merge branch 'beta' into traduction 2020-09-09 14:28:19 +02:00
Alexandre Iooss 8225445c3e Update translations 2020-09-09 14:10:07 +02:00
Alexandre Iooss f333e6a875 Fix profile picture width 2020-09-09 14:03:49 +02:00
Yohann D'ANELLO e5835b46a5 Backups are sent to Zamok 2020-09-08 13:31:22 +02:00
Yohann D'ANELLO fe937405a6 Merge remote-tracking branch 'origin/beta' into beta 2020-09-08 10:11:44 +02:00
Yohann D'ANELLO 0741c8ad2b Refactor the script to extract the mails that are registered to an events mailing list 2020-09-08 10:11:33 +02:00
ynerant 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
Yohann D'ANELLO 428de69d93 Fix permissions to let treasurers to make some initial registrations 2020-09-07 23:36:50 +02:00
ynerant 9b8caa7fa1 Merge branch 'beta' into 'master'
Add animated profile picture support

See merge request bde/nk20!116
2020-09-07 21:48:28 +02:00
Yohann D'ANELLO fa3c723140 The BDE offers 80 € to each new member that registers to the Société générale 2020-09-07 21:33:23 +02:00
Alexandre Iooss dc6a5f56f6 Remove WEI mention from register page and mail 2020-09-07 19:44:54 +02:00
Alexandre Iooss 6b06853678 Add cards to registration apps 2020-09-07 19:28:18 +02:00
Yohann D'ANELLO 346aa94ead Don't trigger signals when we add an object through a permission check 2020-09-07 14:57:30 +02:00
Yohann D'ANELLO 78586b9343 Don't trigger signals when we add an object through a permission check 2020-09-07 14:52:37 +02:00
Yohann D'ANELLO 353416618a Linebreaks are rendered as <<BR>> in the wiki 2020-09-07 13:54:06 +02:00
ynerant 9eff3d8850 Merge branch 'no_null_charfield' into 'beta'
Add __str__ to models, remove null=True in CharField and TextField

See merge request bde/nk20!117
2020-09-07 11:39:19 +02:00
Yohann D'ANELLO 7a32c30b8c Model names translations were missing 2020-09-07 11:23:05 +02:00
Yohann D'ANELLO 0183ba193c Plain text mode in reports 2020-09-07 11:07:31 +02:00
Yohann D'ANELLO f3f746aba8 Plain text mode in reports 2020-09-07 11:03:58 +02:00
Yohann D'ANELLO 53c4e38771 Add __str__ to models, remove null=True in CharField and TextField 2020-09-07 01:06:22 +02:00
Alexandre Iooss 4a9c37905c Fix alias count on club info 2020-09-06 21:54:12 +02:00
Alexandre Iooss 4452d112e3 Navbar should expand only on large screen 2020-09-06 21:50:06 +02:00
Alexandre Iooss 27aa2e9da8 JQuery is unable to cancel Turbolinks 2020-09-06 21:41:33 +02:00
Alexandre Iooss 89b2ff52e3 Fix I'm the emitter button 2020-09-06 21:38:55 +02:00
Alexandre Iooss 48407cacf8 Call subprocesses with absolute path 2020-09-06 21:19:17 +02:00
Alexandre Iooss b6901ea1e5 Use flake8-django 2020-09-06 20:49:06 +02:00
Alexandre Iooss 012b84614c Hide asterix on login form 2020-09-06 20:32:46 +02:00
Yohann D'ANELLO 3988261a64 Tippfehler 2020-09-06 20:25:12 +02:00
Alexandre Iooss c06354211b Translate login page 2020-09-06 20:21:31 +02:00
Yohann D'ANELLO 1023c6c502 Use pre_delete signal insted of Model.delete() to prevent note balance issues when deleting a transaction (don't do it) in Django Admin 2020-09-06 20:18:59 +02:00
Alexandre Iooss cc5996121b Change HTML localization 2020-09-06 20:04:10 +02:00
Alexandre Iooss 40a3405f47 Fix missing spaces before comment 2020-09-06 19:16:35 +02:00
Alexandre Iooss 82924c999a Add animated profile picture support 2020-09-06 18:54:21 +02:00
ynerant f1dac73c08 Merge branch 'beta' into 'master'
First release

See merge request bde/nk20!115
2020-09-06 17:03:51 +02:00
Yohann D'ANELLO 72c004cb56 Remove ugly semicolon in invoices template, release first version 🎉 2020-09-06 16:49:06 +02:00
ynerant 1ed74021a2 Merge branch 'beta' into 'master'
v1.0.0

See merge request bde/nk20!114
2020-09-06 16:06:26 +02:00
Alexandre Iooss b1fed3d476 Remove .png in invoice model 2020-09-06 15:49:44 +02:00
Yohann D'ANELLO d5f324c2d5 Test the render of the rights page (more coverage, yeah) 2020-09-06 15:32:18 +02:00
Alexandre Iooss dcdd8e56e8 Migrate LaTeX to XeTeX 2020-09-06 15:30:12 +02:00
Alexandre Iooss ae028b7d06 Disable pdflatex interactivity 2020-09-06 13:36:07 +02:00
Yohann D'ANELLO 5ebdb015ad Decompose some membership functions, now we have a good linting :) 2020-09-06 13:30:38 +02:00
Alexandre Iooss 69f87c0f64 Dev should do a recursive clone 2020-09-06 13:16:47 +02:00
Alexandre Iooss eb58db7df9 Inscrease max function complexity to 15 2020-09-06 13:13:29 +02:00
ynerant 81ad38927d Merge branch 'beta' into 'master'
Fix note pictures, better ansible

See merge request bde/nk20!113
2020-09-06 13:09:28 +02:00
Yohann D'ANELLO 8aac738c4a Treasurers can see any profile and change the note picture of their clubs 2020-09-06 12:55:27 +02:00
Yohann D'ANELLO eb4641ed35 Upload button wasn't translated 2020-09-06 12:35:59 +02:00
Yohann D'ANELLO ae31cdf15e Group ansible hosts 2020-09-06 12:32:17 +02:00
erdnaxe fcd1bb98a8 Merge branch 'image_upload' into 'beta'
Move image upload code to form clean

See merge request bde/nk20!112
2020-09-06 12:30:45 +02:00
Alexandre Iooss 15ed9d81d5 Check image size before sending it 2020-09-06 12:16:36 +02:00
Alexandre Iooss de3660b23c Move image upload code to form clean 2020-09-06 12:04:54 +02:00
Yohann D'ANELLO 487c3ef0da Human-readable activity wiki page should be updated in cron file 2020-09-06 11:11:02 +02:00
Yohann D'ANELLO af48eeeaec Activities refresh should not be commented in cron file 2020-09-06 11:04:06 +02:00
Alexandre Iooss c503e77b23 Fix some typo in Ansible 2020-09-06 10:46:03 +02:00
Yohann D'ANELLO 1a28e876b8 Rework on Ansible config, this is now more universal 2020-09-06 10:32:52 +02:00
Alexandre Iooss 2a824cadf6 COPYING is standard in the GNU project 2020-09-06 09:47:39 +02:00
Alexandre Iooss 2a1bfa9735 Update Django CAS server to 1.2.0 2020-09-06 09:41:32 +02:00
Yohann D'ANELLO a64dc9ffc2 Certbot and Nginx disappeared in Ansible conf 2020-09-06 09:38:27 +02:00
Yohann D'ANELLO b63fa19644 With normal rights, notes were displayed as there were inactive 2020-09-06 09:10:57 +02:00
ynerant 2f6c7ed156 Merge branch 'beta' into 'master'
Remove padding around note picture

See merge request bde/nk20!111
2020-09-05 19:41:28 +02:00
Yohann D'ANELLO 00bc9550f2 Add padding to the the note picture (cc shirenn) 2020-09-05 19:27:22 +02:00
Yohann D'ANELLO be8e74d056 If a note is saved and the main name changed without changing the normalized form, update the main alias 2020-09-05 15:41:47 +02:00
ynerant 2b2dde85dc Merge branch 'beta' into 'master'
Few fixes

Closes #61

See merge request bde/nk20!110
2020-09-05 14:52:29 +02:00
Yohann D'ANELLO 9f619a9df8 Center profile picture in transfer interface, closes #61 2020-09-05 14:36:49 +02:00
Yohann D'ANELLO 96954b1afd Club managers can change the picture of the club note 2020-09-05 14:32:47 +02:00
Yohann D'ANELLO 2a8a5cd736 Fix some linting, some complex functions are remaining 2020-09-05 14:29:40 +02:00
Yohann D'ANELLO e73b3cf69d Fix refresh activities cron 2020-09-05 14:28:05 +02:00
Yohann D'ANELLO 2e13356e39 Fix a bug in note saving 2020-09-05 13:52:03 +02:00
Yohann D'ANELLO d273193b1d Save the list of changed usernames and lost aliases 2020-09-05 13:51:00 +02:00
Yohann D'ANELLO 3e9b3d690f Note is up on note.crans.org 2020-09-05 11:23:11 +02:00
ynerant 863150d200 Merge branch 'beta' into 'master'
Last deployment fixes

See merge request bde/nk20!109
2020-09-05 11:21:07 +02:00
Yohann D'ANELLO f96b1f26a4 Skip invoice rendering (have to fix later) 2020-09-05 11:07:04 +02:00
Yohann D'ANELLO 466db42318 Add texlive-latex-recommended in Gitlab-CI 2020-09-05 10:39:33 +02:00
Yohann D'ANELLO 3af083fb6b Remove temporary bera font in invoices, add texlive-base-recommended for invoices 2020-09-05 10:34:19 +02:00
Yohann D'ANELLO bcb2398d68 Use migrations instead of fixtures to create BDE, Kfet and special notes 2020-09-05 10:33:24 +02:00
Yohann D'ANELLO 8c23726f88 Don't rebuild systematically migrations 2020-09-05 10:07:32 +02:00
Yohann D'ANELLO 751a4291ab We are in production, then we commit migrations 2020-09-05 10:05:17 +02:00
Yohann D'ANELLO 77b0241406 Log TeX error directly 2020-09-05 09:00:16 +02:00
Alexandre Iooss afc367cfb8 Use === in JS 2020-09-05 08:38:31 +02:00
Alexandre Iooss bad5fe3c22
Format JS files 2020-09-05 08:30:41 +02:00
Alexandre Iooss a97a36bc9e Add apps/script submodule to CI 2020-09-05 08:17:27 +02:00
Yohann D'ANELLO 94706328ff Tests can run between 12pm and 2am 2020-09-05 00:47:55 +02:00
Yohann D'ANELLO 2fc13e5418 Edit the wiki after an activity update iff the wiki password is defined, and don't run the script asynchronous with a SQLite database 2020-09-05 00:47:30 +02:00
Yohann D'ANELLO 2e80233cbc Change debug option to "print stdout" / "edit wiki" in the Refresh activities script 2020-09-05 00:45:14 +02:00
ynerant ebe6ce61e4 Merge branch 'beta' into 'master'
Fix Ansible script for production

See merge request bde/nk20!108
2020-09-04 22:42:42 +02:00
Yohann D'ANELLO 0f47412c38 Fix Ansible script for production 2020-09-04 22:37:18 +02:00
Pierre-antoine Comby 3c636e9f71 Merge branch 'beta' into 'master'
Beta

Closes #59

See merge request bde/nk20!107
2020-09-04 22:32:14 +02:00
Yohann D'ANELLO 4ddd763886 Test activity app 2020-09-04 21:46:40 +02:00
elkmaennchen 0888afe439 I am hungry, so I ham hungry 2020-09-04 20:38:57 +02:00
Yohann D'ANELLO 6d1b75b9b6 Fix linebreaks in ICS file 2020-09-04 19:24:48 +02:00
Yohann D'ANELLO 70e1a611dd Export activites as an ICS Calendar 2020-09-04 18:36:20 +02:00
elkmaennchen 3111c30e56 Add spanish translation 2020-09-04 18:24:49 +02:00
Yohann D'ANELLO 5c7fe716ad Fix JSON 2020-09-04 16:43:57 +02:00
Yohann D'ANELLO 9b4923fc04 Fix some permissions, grant temporary all treasurers to make transactions from anyone to anyone while a better system is not implemented 2020-09-04 16:37:17 +02:00
Yohann D'ANELLO c93c81861d Users can change their password, fix #59 2020-09-04 16:28:50 +02:00
Yohann D'ANELLO f71fb1fa81 Use pre-defined queryset by default in API views 2020-09-04 16:02:42 +02:00
Yohann D'ANELLO c03c18e93a Test and cover treasury app 2020-09-04 15:53:00 +02:00
Alexandre Iooss b6847415b5 Remove unused imports in tests 2020-09-04 07:53:31 +02:00
Alexandre Iooss cb545417ac Linting does not require deps 2020-09-04 07:47:52 +02:00
Alexandre Iooss f8a0e20772 Regenerate locales 2020-09-04 07:44:59 +02:00
Pierre-antoine Comby 43fffdf56f Merge branch 'traduction' into beta 2020-09-03 23:48:39 +02:00
Pierre-antoine Comby c66d66bc64 fertig ! 2020-09-03 23:47:31 +02:00
Alexandre Iooss d29e1d69d1 Format api viewsets 2020-09-03 21:47:08 +02:00
Alexandre Iooss ff187581c9 Remove useless blank lines and spaces in api app 2020-09-03 21:21:19 +02:00
Yohann D'ANELLO f02efd3b39 100% coverage on registration app 2020-09-03 20:03:40 +02:00
Yohann D'ANELLO 4b85a35a9d Fix double consumptions 2020-09-03 16:10:29 +02:00
Pierre-antoine Comby f7f6f053f7 Format date to ISO standard 2020-09-03 14:33:26 +02:00
Pierre-antoine Comby 22bae51808 mehr Deutsch 2020-09-03 14:23:00 +02:00
Alexandre Iooss 76aacaf048 Definitively more usable 2020-09-03 11:47:17 +02:00
Alexandre Iooss cc7ebd2d8a Sometimes the nk20 is too laggy 2020-09-03 00:35:25 +02:00
Pierre-antoine Comby 42778baf20 51% der Übersetzung, Sandmännchen ruf an. 2020-09-03 00:02:45 +02:00
Yohann D'ANELLO fed9567522 Force line breaks on transactions reason in history, but don't wrap dates or amounts 2020-09-02 23:49:10 +02:00
Pierre-antoine Comby 177128f593 do not gather build in translation. 2020-09-02 23:26:28 +02:00
Alexandre Iooss 7bdf5a4366 Update picture path in member test 2020-09-02 23:25:32 +02:00
Pierre-antoine Comby 4b149213f9 Anfang der Deutschen Übersetzung 2020-09-02 23:15:20 +02:00
Pierre-antoine Comby 7fc2559530 French update 2020-09-02 23:14:35 +02:00
Alexandre Iooss be6cf93cdb Move default profile picture in member app 2020-09-02 23:08:40 +02:00
Yohann D'ANELLO bf7f5b9cd6 Test and cover fully member app 2020-09-02 22:54:01 +02:00
Yohann D'ANELLO 1b8cb7abb0 Send user id and group id in Docker console 2020-09-02 22:53:43 +02:00
Alexandre Iooss 3d20987b18 Ignore W503 in linting 2020-09-02 22:33:31 +02:00
Alexandre Iooss 06679b2e6a Remove deprecation warning and enable some linting rules 2020-09-02 22:27:04 +02:00
Pierre-antoine Comby 9d1a355ea1 French translation 2020-09-02 21:14:02 +02:00
Pierre-antoine Comby 6a2b46be72 make API token button nicer 2020-09-02 19:16:08 +02:00
Pierre-antoine Comby 4da5c41f40 move viewsets and serializers out of urls.py 2020-09-02 19:00:04 +02:00
Yohann D'ANELLO 8db9e92986 Sqlite does not support order by in subqueries 2020-09-02 18:01:41 +02:00
Pierre-antoine Comby 3e42f4fffb superusers at creation gets automatically valid registration 2020-09-02 17:22:06 +02:00
erdnaxe cde35ea9f9 Merge branch 'doc_install' into 'beta'
Doc install

See merge request bde/nk20!106
2020-09-02 16:24:30 +02:00
Alexandre Iooss f1fe6c4996 Fix some pip requirements versions 2020-09-02 15:52:44 +02:00
Alexandre Iooss d73d9f8bda Add Debian requirements to Pip 2020-09-02 15:45:07 +02:00
Alexandre Iooss e85ec1fa05 Fix TOC link in README 2020-09-02 15:31:57 +02:00
Alexandre Iooss d74007d523 TOC and update dev instructions 2020-09-02 15:30:03 +02:00
Yohann D'ANELLO cc5f04e2b3 Add script to launch a Docker bash easily 2020-09-02 15:26:57 +02:00
ynerant d054d58661 Merge branch 'beta' into 'master'
Better CI

See merge request bde/nk20!105
2020-09-02 13:17:37 +02:00
Alexandre Iooss cf7101fc0f Less deps in README and ansible role 2020-09-02 12:57:46 +02:00
Alexandre Iooss fb47e22ae1 Remove LaTeX extra 2020-09-02 12:30:38 +02:00
Yohann D'ANELLO 980032bfbf Remove ltablex and tabularx TeX depency 2020-09-02 12:23:45 +02:00
Alexandre Iooss 31585a9c7e Revert to a simpler CI 2020-09-02 12:14:41 +02:00
Pierre-antoine Comby 9f42ecb97a Merge branch 'beta' into 'master'
Beta

Closes #52, #54, #55, #56 et #57

See merge request bde/nk20!104
2020-09-02 10:00:22 +02:00
Alexandre Iooss b5028b9814 Desactivate docker entrypoint on CI 2020-09-01 22:42:56 +02:00
Alexandre Iooss 0e8557404d Fix gitlab ci format 2020-09-01 22:26:34 +02:00
Alexandre Iooss 0f56e90e48 Also keep local cache for CI 2020-09-01 22:25:16 +02:00
Alexandre Iooss 9bd7569935 Cache Docker image creation 2020-09-01 22:22:36 +02:00
Alexandre Iooss a3fe13aeb4 Use docker image for CI 2020-09-01 22:17:48 +02:00
Alexandre Iooss 22140a1428 Remove LaTeX font-extra from Docker image 2020-09-01 21:19:37 +02:00
Alexandre Iooss cdae654034 Do not install recommends in Docker 2020-09-01 21:13:45 +02:00
Alexandre Iooss ebe9c62823 Use v1 auth to dockerhub 2020-09-01 19:59:52 +02:00
Yohann D'ANELLO d76aa3fec9 Some table accessors weren't updated 2020-09-01 19:04:35 +02:00
Alexandre Iooss 05164636a1 Switch to kaniko to build docker image 2020-09-01 17:59:56 +02:00
Yohann D'ANELLO 361ea8cad3 Update Django Tables 2, change accessor from dot to __ 2020-09-01 17:58:58 +02:00
Alexandre Iooss 819795c1f9 Add docker build to CI 2020-09-01 17:33:03 +02:00
Yohann D'ANELLO b5c1289358 Update PIP dependencies 2020-09-01 17:19:53 +02:00
Alexandre Iooss 0395717d19 Add instructions for backports 2020-09-01 16:54:34 +02:00
Yohann D'ANELLO 4bb8a443d5 Update sample Traefik config 2020-09-01 16:49:55 +02:00
Alexandre Iooss 646c23ccb8 Fix note.cron in ansible 2020-09-01 16:42:53 +02:00
Alexandre Iooss cf4e1f33b4 Add mirror variable 2020-09-01 16:35:43 +02:00
Alexandre Iooss 5efb150583 Update Ansible packages 2020-09-01 16:32:13 +02:00
erdnaxe 08defd84e6 Merge branch 'debian_deps' into 'beta'
Debian deps

See merge request bde/nk20!103
2020-09-01 16:09:00 +02:00
Yohann D'ANELLO 7c9287e387 Test and cover note app 2020-09-01 15:54:56 +02:00
Yohann D'ANELLO c6abad107a RecurrentTransaction has no longer a category 2020-09-01 15:54:35 +02:00
Yohann D'ANELLO 81e418e17e Use DateTimeField instead of Field in Transaction search form 2020-09-01 15:53:56 +02:00
Yohann D'ANELLO 1977e403e3 History tables are not orderable 2020-09-01 15:52:54 +02:00
Yohann D'ANELLO eaf256b1b6 Fix mails when the user or the club has a negative balance 2020-09-01 15:52:27 +02:00
Yohann D'ANELLO 2b70a05a9e Remove useless category field in RecurrentTransaction (that is the category of the template) 2020-09-01 15:51:47 +02:00
Yohann D'ANELLO be08c12dca Don't cover scripts app (don't import old data everytime) 2020-09-01 15:49:47 +02:00
Alexandre Iooss aa247c281f Fix tzdata prompt during apt install 2020-09-01 15:42:00 +02:00
Alexandre Iooss 739da3a090 CI 4 VS erdnaxe 0 2020-09-01 15:19:06 +02:00
Alexandre Iooss 85c9c4b2ba CI 3 VS erdnaxe 0 2020-09-01 15:13:25 +02:00
Alexandre Iooss 27845303b8 CI 2 VS erdnaxe 0 2020-09-01 15:13:08 +02:00
Alexandre Iooss e3fc79231d CI 1 VS erdnaxe 0 2020-09-01 14:53:11 +02:00
Alexandre Iooss 9b2a8c4f6f Try to fix Gitlab CI 2020-09-01 14:47:03 +02:00
Alexandre Iooss d89f6dcf5c Update install instructions 2020-09-01 14:39:03 +02:00
Alexandre Iooss 5feb23ad51 Use Debian font awesome 2020-09-01 14:33:38 +02:00
Alexandre Iooss b4ef4b8089 Use local javascript and css libs 2020-09-01 14:28:11 +02:00
Alexandre Iooss 6de46a9264 Remove useless nginx/uwsgi config 2020-09-01 14:19:38 +02:00
Alexandre Iooss bfd08fec09 Working Docker production env 2020-09-01 14:16:18 +02:00
Alexandre Iooss 55be6be6c5 Docker instruction before Docker compose 2020-09-01 14:03:06 +02:00
Alexandre Iooss bf7b187048 Working docker devserver 2020-09-01 13:52:28 +02:00
Alexandre Iooss 09853ce990 Use debian deps in Dockerfile 2020-09-01 12:09:02 +02:00
Alexandre Iooss 2acb47c516 Base docker on Debian Buster 2020-09-01 11:23:56 +02:00
Alexandre Iooss ff7e954652 Add missing card on button edit 2020-09-01 10:47:51 +02:00
Alexandre Iooss 9c794205f0 More readable navbar and no more padding in mark 2020-09-01 10:47:29 +02:00
erdnaxe 3922fcd93a Merge branch 'no_content_title' into 'beta'
No content title

See merge request bde/nk20!102
2020-09-01 10:35:04 +02:00
Alexandre Iooss 534831f380 Use buttons in profile page 2020-09-01 10:30:47 +02:00
Alexandre Iooss dd9ca315fa Clean up templates header 2020-09-01 10:20:16 +02:00
Alexandre Iooss d9e003a8f4 Remove contenttitle 2020-09-01 10:13:05 +02:00
erdnaxe dbca5db7d7 Merge branch 'front_erdnaxe' into 'beta'
Front erdnaxe

See merge request bde/nk20!101
2020-09-01 10:07:25 +02:00
Alexandre Iooss c1c211629d Change loading bar color 2020-09-01 10:02:22 +02:00
Alexandre Iooss b787c8cfe2 Do not make long alias break layout 2020-09-01 09:56:19 +02:00
Alexandre Iooss affa2b1a4d Autocomplete of fixed size 2020-09-01 09:52:45 +02:00
Yohann D'ANELLO e0c1a5f590 Move highlighted buttons under the note selector 2020-09-01 09:46:56 +02:00
Alexandre Iooss e8dcf295ad Make outline button have a background 2020-09-01 09:46:33 +02:00
Yohann D'ANELLO 5642c268e9 Move transfer type selector in credit/debit mode 2020-08-31 23:06:21 +02:00
Yohann D'ANELLO e74f92cf8d Set current page as active button 2020-08-31 22:11:46 +02:00
Yohann D'ANELLO ee26850e34 Add a line to describe superusers, remove useless roles in rights table 2020-08-31 21:49:02 +02:00
Yohann D'ANELLO 08c8792aed Fix alias deletion 2020-08-31 21:32:45 +02:00
Yohann D'ANELLO a9da4a38e1 Order superusers by last name 2020-08-31 21:15:09 +02:00
Yohann D'ANELLO b8c1cfba40 Display superusers in rights list 2020-08-31 21:11:00 +02:00
Yohann D'ANELLO ca6f7cac9a Translate "lock note" messages 2020-08-31 20:32:28 +02:00
Yohann D'ANELLO 5e65e2d74a Add "Lock note" feature 2020-08-31 20:15:48 +02:00
Yohann D'ANELLO 0c753c3288 Prevent also club owners when the note balance is negative 2020-08-31 16:13:26 +02:00
Yohann D'ANELLO 1bbe7df797 API app must have no dependency 2020-08-31 00:49:41 +02:00
Yohann D'ANELLO abbe74cc55 Add activity type "Other" 2020-08-31 00:20:09 +02:00
Yohann D'ANELLO 8744455cbe Add placeholders in activity form 2020-08-31 00:15:02 +02:00
Yohann D'ANELLO 56c41258b9 Highlight non-validated activities 2020-08-30 23:54:54 +02:00
Yohann D'ANELLO 48eb0749e0 Users can create a past activity 2020-08-30 23:14:57 +02:00
Yohann D'ANELLO 8ac551e1bc Hide activity creater if the user is not able to validate it 2020-08-30 23:10:41 +02:00
Yohann D'ANELLO 805ceda249 Don't display the alias create form if the user can't create anyone 2020-08-30 23:06:51 +02:00
Yohann D'ANELLO a9258c332a Order note research results: match first aliases then normalized names 2020-08-30 22:33:59 +02:00
Yohann D'ANELLO ca7f4791ed Preserve dashes in Alias normalisation 2020-08-30 17:28:36 +02:00
Yohann D'ANELLO b454ad8dad Hide note selector when the user clicks elsewhere 2020-08-30 16:56:16 +02:00
Yohann D'ANELLO 7d539d44e5 Display form error when a permission is missing rather than display a 403 page 2020-08-30 16:23:55 +02:00
Yohann D'ANELLO 227cb2a801 Add light background to "Gift/Transfer" buttons 2020-08-30 15:49:06 +02:00
Yohann D'ANELLO ef1e805538 Clear note selector once a consumption is performed 2020-08-30 15:18:34 +02:00
Yohann D'ANELLO 374e6ed7f8 💚 Fix CI 2020-08-30 11:59:10 +02:00
Yohann D'ANELLO c5f40e0952 🐛 Fix entry page view 2020-08-29 23:06:50 +02:00
Alexandre Iooss 4cb162de87 Card for wei templates 2020-08-25 18:36:49 +02:00
Alexandre Iooss bca301700d Cards for all treasury 2020-08-25 18:10:21 +02:00
erdnaxe 5ba18a2d89 Merge branch 'front_activity' into 'beta'
Cards for activity templates

See merge request bde/nk20!100
2020-08-25 17:40:05 +02:00
Alexandre Iooss 22a0af640e Cards for activity templates 2020-08-25 17:39:30 +02:00
Alexandre Iooss 1712d1725a Update translations 2020-08-25 16:47:54 +02:00
erdnaxe 93e5e4c8cd Merge branch 'morefront' into 'beta'
Do not list alias on profile page

See merge request bde/nk20!99
2020-08-25 16:44:55 +02:00
Alexandre Iooss 1fd37bb1ce Smaller membership renew btn 2020-08-25 16:44:01 +02:00
Alexandre Iooss e3785e11f1 Cards everywhere in member app 2020-08-25 16:30:02 +02:00
Alexandre Iooss 2e659c63cd Member templates inherit from member/base.html 2020-08-25 15:39:57 +02:00
Alexandre Iooss 63dc184ce4 Do not list alias on profile page 2020-08-25 15:05:50 +02:00
Yohann D'ANELLO b25935e579 When data is imported from the NK15, prevent users whenever some aliases are deleted 2020-08-24 12:41:51 +02:00
Yohann D'ANELLO 550242226e 🎨 Normalize - to _ since these characters are used a lot 2020-08-24 11:54:00 +02:00
erdnaxe bac14521ae Merge branch 'more_front' into 'beta'
More front

See merge request bde/nk20!97
2020-08-23 14:21:53 +02:00
Alexandre Iooss e14c8734c2 Template formating on member app 2020-08-23 14:21:31 +02:00
Alexandre Iooss c64de202a6 Remove title from error pages 2020-08-23 14:21:13 +02:00
Alexandre Iooss 44b7fe8f52 List aliases on profile page 2020-08-23 12:07:04 +02:00
Alexandre Iooss cbc3e39bd6 Create base template for member and wei 2020-08-23 10:06:16 +02:00
Alexandre Iooss 1c16d6ef18 Use block.parent to extend content 2020-08-23 09:56:42 +02:00
Alexandre Iooss e3898d0b1e Cards on error pages 2020-08-23 09:51:03 +02:00
erdnaxe ac0e9b9da2 Merge branch 'more_front' into 'beta'
More front

See merge request bde/nk20!96
2020-08-23 00:35:31 +02:00
Alexandre Iooss 0ba77fb8f0 Make alias form a bit more HTML friendly 2020-08-23 00:33:36 +02:00
Alexandre Iooss 342d3910c7 Set fluid container on parent template 2020-08-23 00:03:10 +02:00
Alexandre Iooss 2c1cf148fa Remove alias_update.html 2020-08-22 23:55:22 +02:00
Alexandre Iooss 196f796570 Move alias.js to local static 2020-08-22 23:54:58 +02:00
Alexandre Iooss 8691421ce3 Change cursor on select 2020-08-22 23:23:44 +02:00
Alexandre Iooss 9cad8fcc65 Simplify future user search 2020-08-22 10:13:48 +02:00
Alexandre Iooss 891955cedf Cards for all rights template 2020-08-22 10:01:22 +02:00
Alexandre Iooss 8063354e0f He's stupid Tim\! 2020-08-22 09:39:07 +02:00
erdnaxe f077a5d72f Merge branch 'unified_search' into 'beta'
Unified search

See merge request bde/nk20!95
2020-08-22 09:38:03 +02:00
Alexandre Iooss 2272cf5294 Update URL when searching 2020-08-22 09:35:55 +02:00
Alexandre Iooss 8465b24d7d Use base search for club list 2020-08-21 23:20:45 +02:00
Alexandre Iooss aa98c4848d Create base template for search page 2020-08-21 23:11:25 +02:00
erdnaxe 00b07147f6 Merge branch 'color_front' into 'beta'
Color front

See merge request bde/nk20!94
2020-08-21 21:07:24 +02:00
Yohann D'ANELLO 26775aa561 Clear and hide tooltips when the conso/transfer page is cleared 2020-08-21 21:00:46 +02:00
Alexandre Iooss 83d2c18d1e Debounce user search 2020-08-21 19:12:28 +02:00
Alexandre Iooss 5ea1eed76d Password reset use cards 2020-08-21 18:34:20 +02:00
Alexandre Iooss b7d4a17ffd Better footer on mobile phone 2020-08-21 18:08:32 +02:00
Alexandre Iooss 5c3451bda7 Use cards for registration templates 2020-08-21 17:52:48 +02:00
Alexandre Iooss 8c46321c95 Make page title always white 2020-08-21 17:52:30 +02:00
Alexandre Iooss a3af2b0d9a Hide login select arrow on firefox 2020-08-21 17:52:10 +02:00
Alexandre Iooss 501d02d05c Fix back to top btn 2020-08-21 15:25:39 +02:00
Alexandre Iooss 310f55a28e Light background on login box 2020-08-21 14:43:25 +02:00
Alexandre Iooss 197bd28ceb Use a fluid-container for navbar 2020-08-21 14:38:07 +02:00
Alexandre Iooss e03a3f7fd2 Do not show login and register on these pages 2020-08-21 14:33:43 +02:00
Alexandre Iooss 51230e029d Better navbar buttons and less shadow 2020-08-21 14:21:26 +02:00
Alexandre Iooss bd49a36bcc Dark background and custom navbar color 2020-08-21 13:24:04 +02:00
Rida Lali 2672721235 Add blocks with collapse animation instead of display all 2020-08-21 08:18:00 +02:00
Yohann D'ANELLO ba636fc401 Transfer from the Société générale is antedated 2020-08-21 07:40:27 +02:00
Yohann D'ANELLO c090b4af76 Superusers can see their note even if they have no membership for local dev 2020-08-20 23:13:27 +02:00
Pierre-antoine Comby a1dc8fe530 fix trailing comma 2020-08-19 23:00:49 +02:00
Pierre-antoine Comby 6ea92cdcde Merge branch 'documents' into beta 2020-08-19 13:18:12 +02:00
Pierre-antoine Comby 9c9214b5df [registration] comments 2020-08-19 13:03:36 +02:00
Pierre-antoine Comby 00935a8c02 [activity] comments on view and forms 2020-08-19 11:31:15 +02:00
Pierre-antoine Comby b0ebc7c0a4 mv imageForm 2020-08-19 11:30:56 +02:00
Pierre-antoine Comby 60b1cdbcf8 comments member views 2020-08-18 18:19:39 +02:00
Pierre-antoine Comby f324965f1a [note] comments view and templates 2020-08-18 14:27:04 +02:00
Yohann D'ANELLO 7c291b115a Ensure that date_end ≥ date_start in activities 2020-08-18 12:10:52 +02:00
Yohann D'ANELLO 6217f35f67 Notes are force-updated when a transaction is saved 2020-08-18 11:46:35 +02:00
Pierre-antoine Comby 448d379315 no need to disable turbolinks if we don't use select2 2020-08-18 11:45:30 +02:00
Yohann D'ANELLO e974eaa1fe Tuple (name, date_start, date_end) must be unique for activities 2020-08-17 19:28:26 +02:00
Yohann D'ANELLO 61ace4af74 Replace timezone.now().date() by date.today() 2020-08-16 00:36:34 +02:00
Yohann D'ANELLO b8c3dda95b Replace timezone.now().date() by date.today() 2020-08-16 00:35:13 +02:00
Yohann D'ANELLO da23df05cb Kfet members can edit their own WEI registration 2020-08-16 00:15:33 +02:00
Yohann D'ANELLO 9c061d9837 Free the transfer lock in case of transfer error 2020-08-16 00:04:35 +02:00
Yohann D'ANELLO 5abb155287 Free the transfer lock in case of transfer error 2020-08-16 00:02:09 +02:00
Yohann D'ANELLO 9f258e39b6 Check that the user is a member of the parent club only at the creation of the membership 2020-08-15 23:52:57 +02:00
Yohann D'ANELLO 4997a37058 Ensure that the user is authenticated before that it has the permission to see page 2020-08-15 23:27:58 +02:00
Yohann D'ANELLO b16871d925 Display a form error rather than a page error if a guest is already invited 2020-08-15 23:03:49 +02:00
Yohann D'ANELLO 1186b0f9a9 Don't serialize *_ptr fields in logs 2020-08-15 22:54:16 +02:00
Yohann D'ANELLO 5abbb84254 Permissions for activities must be more specific to prevent that anyone can validate its own activity 2020-08-15 22:24:48 +02:00
Yohann D'ANELLO 5f8c4a2857 Prevent time travelers to register in the note 2020-08-15 21:30:08 +02:00
Yohann D'ANELLO 14b969b2dd Fix link in negative balances mails 2020-08-15 21:12:16 +02:00
Yohann D'ANELLO f95a0875db Fix link in negative balances mails 2020-08-15 21:11:02 +02:00
Yohann D'ANELLO 430036bfc2 Don't display "change password" button on other profile pages 2020-08-15 20:59:45 +02:00
Yohann D'ANELLO d6fd925fdd Display email and phone number in profile page 2020-08-15 20:40:11 +02:00
Yohann D'ANELLO 89c15cbe3e Refresh filters to search a transaction when a source or a destination is selected 2020-08-15 20:19:34 +02:00
Yohann D'ANELLO 75cd34f5dd Enlarge buttons table and transactions table 2020-08-15 20:04:19 +02:00
Yohann D'ANELLO 6927f5fbb6 Search buttons by category or description, highlight matched words 2020-08-15 19:47:29 +02:00
Yohann D'ANELLO 482a04d37c Consumptions didn't get removed properly 2020-08-15 19:33:30 +02:00
Yohann D'ANELLO 0bf5067b60 Fix linters 2020-08-15 19:10:23 +02:00
Yohann D'ANELLO fe2af5ac2b Pass resourcetype argument correctly when invalidating a transaction 2020-08-15 19:10:15 +02:00
Yohann D'ANELLO d4090a4043 🎉 Use select_for_update tag to update note balances when we save a Transaction to avoid concurrency issues 2020-08-15 18:57:44 +02:00
Yohann D'ANELLO 242b85676d Floats are already formatted 2020-08-14 19:37:17 +02:00
Yohann D'ANELLO eca4767155 Mark fields in TeX templates as safe 2020-08-14 19:35:21 +02:00
Yohann D'ANELLO 21ba46c1bc Don't escape numbers in TeX template 2020-08-14 19:16:51 +02:00
Yohann D'ANELLO 74097ecc44 "safe" template tag is not made for TeX templates, it replaces ' with &#39; but & is a special character 2020-08-14 19:13:24 +02:00
Yohann D'ANELLO d962763987 datetime.today() => date.today() 2020-08-14 19:04:44 +02:00
Yohann D'ANELLO a43abee00b Don't log database changes when we check a permission 2020-08-14 19:00:57 +02:00
Yohann D'ANELLO 912ce5da2e Fix the amount history in the button update page 2020-08-13 20:13:00 +02:00
Yohann D'ANELLO 29f8b9215d Fix the amount history in the button update page 2020-08-13 20:06:06 +02:00
Yohann D'ANELLO f5f379e6ad BooleanField -> CharField (a locale name is not a boolean) 2020-08-13 19:48:15 +02:00
Yohann D'ANELLO c50fdd6689 Move the mailing list registration to the Profile model, see #50 2020-08-13 19:43:37 +02:00
Yohann D'ANELLO 1e4cbf60c5 Display the full price of the WEI, including the BDE and the Kfet membership 2020-08-13 19:29:01 +02:00
Yohann D'ANELLO dfe4bf2175 Register external apps in Django Admin, fix Django Admin Docs 2020-08-13 19:13:19 +02:00
Yohann D'ANELLO a25e663a26 Use datetime.today for DateField 2020-08-13 18:54:53 +02:00
Yohann D'ANELLO 721da093e9 Don't update membership information every time 2020-08-13 18:16:26 +02:00
Yohann D'ANELLO d98e46ffc2 Store note balances in a big integer 2020-08-13 18:04:28 +02:00
Yohann D'ANELLO 2d69e36adf Store only changed data in logs 2020-08-13 17:08:15 +02:00
Yohann D'ANELLO bb2704323a Spam click on invalidity button is no longer possible 2020-08-13 17:04:10 +02:00
Yohann D'ANELLO c466715e8a Raise permission denied on CreateView if you don't have the permission to create a sample instance, see #53 2020-08-13 15:20:15 +02:00
Yohann D'ANELLO 71f6436d06 More WEI tests, > 97 % coverage 2020-08-11 13:30:44 +02:00
Yohann D'ANELLO 106e97f5df Tests are better when they work (fix two tests) 2020-08-11 01:07:45 +02:00
Yohann D'ANELLO b7a88a387c More tests in WEI app, but we can still go further 2020-08-11 01:03:29 +02:00
Yohann D'ANELLO 25e26fe8cf Don't test LaTeX pages if LaTeX is not installed 2020-08-10 23:29:11 +02:00
Yohann D'ANELLO 0fae5b3e62 Create tests for the WEI app 2020-08-10 23:18:40 +02:00
Yohann D'ANELLO 3784e97d60 Hide the credit interface when editing a WEI registration 2020-08-10 20:09:49 +02:00
Yohann D'ANELLO 6567d2f8cc When an user is registering to the WEI, it doesn't pay the membership + the credit amount. The credit amount is deducted instead 2020-08-10 19:59:01 +02:00
Yohann D'ANELLO 999cc0a6b2 Tesdt login page 2020-08-10 19:36:04 +02:00
Alexandre Iooss 60de58b78a Execute tests from apps/ 2020-08-10 18:43:50 +02:00
Yohann D'ANELLO 9c816a288d Stronger alias normalisation, ensure that normalized strings are encoded in ASCII. Closes #52 2020-08-10 18:36:47 +02:00
Alexandre Iooss c277d8bccd Fix CI broken link in README 2020-08-10 18:27:45 +02:00
Alexandre Iooss 4a4c3d33b0 Format README and fix link 2020-08-10 18:26:45 +02:00
Alexandre Iooss 9c679d5bc9 Regen locales 2020-08-10 18:12:59 +02:00
Alexandre Iooss 3b49b7f4c1 Add how to translate in README 2020-08-10 18:12:50 +02:00
Alexandre Iooss 747a878cca Do not hover table when not clickable 2020-08-10 18:01:39 +02:00
Yohann D'ANELLO c612e159cf See user information does not imply see the note balance 2020-08-10 16:32:45 +02:00
Yohann D'ANELLO 1b84c8c603 🐛 The balance must be greater than the *total* amount of a transaction, not the unit price 2020-08-10 16:05:50 +02:00
Yohann D'ANELLO 3a52af33a2 🍻 Make coffee, closes #54 2020-08-10 15:36:41 +02:00
Alexandre Iooss ccfc1e74ac Reorder import statements of apps/activity 2020-08-10 15:30:39 +02:00
Alexandre Iooss 7719ff41ad Make tables responsive 2020-08-10 15:10:02 +02:00
Alexandre Iooss 8933fddaf3 Add comment on custom CSS 2020-08-10 15:05:00 +02:00
Alexandre Iooss eadc8fa193 Fix autocompletion not showing up in rare cases 2020-08-10 14:45:44 +02:00
Alexandre Iooss f74b19b2af Fix autocompletion popup flipping direction 2020-08-10 14:45:17 +02:00
Alexandre Iooss c3081d9cc3 Add missing space in menu 2020-08-10 13:59:29 +02:00
Alexandre Iooss 8e886f1431 Remove debug banner as it does not work 2020-08-10 13:56:06 +02:00
Alexandre Iooss bf7c253607 Set HTML lang depending on user locale 2020-08-10 13:50:23 +02:00
Alexandre Iooss 027ae5b97f Color reauth warning on login page 2020-08-10 13:49:53 +02:00
erdnaxe 63562d3fbb Merge branch 'frontnax' into 'beta'
Frontnax

See merge request bde/nk20!89
2020-08-10 12:25:26 +02:00
Alexandre Iooss bba69f0a60 Give Pikachu as an example 2020-08-10 12:09:05 +02:00
Alexandre Iooss beff848796 Use a fixed-width container by default for lisibility 2020-08-10 12:08:47 +02:00
Alexandre Iooss e78ba49252 Override Django Registration templates 2020-08-10 12:08:21 +02:00
Alexandre Iooss ce35e8f7e8 Make the container customizable 2020-08-10 11:40:51 +02:00
Alexandre Iooss 50f4a43343 Custom CSS and card for login page 2020-08-10 11:31:35 +02:00
Alexandre Iooss b66d6635fc Ignore only collected statics 2020-08-10 11:31:21 +02:00
Alexandre Iooss 9a52c81bff Simplify NGINX examples 2020-08-09 20:33:48 +02:00
Alexandre Iooss 48d3e8960a Do not cover virtualenv and migrations 2020-08-09 19:54:31 +02:00
Alexandre Iooss f6dfbb0b6c Fix amount of \ in apps/activity/views.py 2020-08-09 19:49:11 +02:00
Alexandre Iooss c6e3a57801 Reorder import in apps/treasury/admin.py 2020-08-09 19:43:21 +02:00
Alexandre Iooss 40b826a375 Fix hanging indent in apps/note/tables.py 2020-08-09 19:42:09 +02:00
Alexandre Iooss f0089d0bc5 Remove unused django.template.loader.render_to_string import 2020-08-09 19:39:17 +02:00
Alexandre Iooss 5e75a56eda missing whitespace after ':' in apps/wei/forms/registration.py 2020-08-09 19:38:23 +02:00
Alexandre Iooss d73f7c31a1 Define BASE_DIR in development.py 2020-08-09 19:36:11 +02:00
Alexandre Iooss 31f4105c9a Do not test against Py3.6 2020-08-09 19:34:01 +02:00
Alexandre Iooss e9ae8531b8 Fix date-picker.html import 2020-08-09 19:16:11 +02:00
Alexandre Iooss 7b40ee1ca4 Reorder templates 2020-08-09 19:06:57 +02:00
Alexandre Iooss 53b496546d Add django-bootstrap-datepicker-plus and django-colorfield, move statics 2020-08-09 18:54:20 +02:00
Alexandre Iooss 8c1cf754ed Revert to NOTE_URL 2020-08-09 18:39:17 +02:00
Alexandre Iooss efe833cec3 Merge production settings in base settings 2020-08-09 18:34:51 +02:00
Alexandre Iooss ccfc37d226 Reorder base Django settings and read env vars 2020-08-09 17:52:19 +02:00
Yohann D'ANELLO 764eaafb95 Emails are unique. Translate mail foooters. Closes #55 #56 2020-08-09 16:38:37 +02:00
Yohann D'ANELLO 5846f03220 🐛 Last report date is a datetime, not a date 2020-08-09 15:53:50 +02:00
Yohann D'ANELLO 52e8b46aa2 🐛 Last report date is a datetime, not a date 2020-08-09 15:50:51 +02:00
Yohann D'ANELLO 29f84ea007 Remove test code 2020-08-09 15:42:07 +02:00
Yohann D'ANELLO 49bda926c6 Disable turbolinks for pages that require custom JS, like calendars or autocomplete fields 2020-08-09 15:31:38 +02:00
Yohann D'ANELLO 11fbbca2a8 Amount help text in transaction templates forms can be misleading: they type euros not cents 2020-08-09 14:52:57 +02:00
Yohann D'ANELLO 901af1a86a Use var instead of let to declare a global var (turbolinks...) 2020-08-09 14:01:59 +02:00
Yohann D'ANELLO 5f87e76be8 Fix Profile model in Django Admin 2020-08-09 13:42:37 +02:00
Yohann D'ANELLO 8c885d372b Typo, closes #57 2020-08-09 13:23:22 +02:00
Yohann D'ANELLO 255e4dd0aa Lock interfaces when a transfer is performed to prevent spam click accidents 2020-08-09 13:19:27 +02:00
Yohann D'ANELLO 4afb849aec I hate you JS 2020-08-09 12:42:25 +02:00
Yohann D'ANELLO 872456df20 🐛 Don't break the note 2020-08-09 12:31:06 +02:00
Pierre-antoine Comby 963ba05d01 🎨 set default birthday to 2001-01-01 2020-08-08 16:20:59 +02:00
Pierre-antoine Comby 18eaf4477e 🐛 timezone.now is a DateTime, not a Date 2020-08-08 15:30:33 +02:00
Yohann D'ANELLO e4998cb6e3 [WEI] Implement WEI Survey front 2020-08-07 20:11:28 +02:00
Yohann D'ANELLO ad59b5c81e Change help text in membership fields in club form 2020-08-07 14:20:34 +02:00
Yohann D'ANELLO 88917dde23 Some memberships were detected twice 2020-08-07 14:00:54 +02:00
Yohann D'ANELLO aab194b987 Import Société générale credits 2020-08-07 13:19:00 +02:00
Yohann D'ANELLO 9751a5ad92 🐛 Fix pagination in transaction page 2020-08-07 12:55:07 +02:00
Yohann D'ANELLO 679ac3a652 Lock invoices, delete them 2020-08-07 11:04:54 +02:00
Yohann D'ANELLO 1fb14ea33d Store invoice source code instead of generate it everytime 2020-08-06 22:30:14 +02:00
Yohann D'ANELLO e23eafd56c Add invoices in Django Admin 2020-08-06 21:51:53 +02:00
Yohann D'ANELLO 3e28ed8716 Remove space in IBAN 2020-08-06 21:45:50 +02:00
Yohann D'ANELLO 5c01c0bb6c Display 2 decimals in invoices 2020-08-06 21:02:25 +02:00
Yohann D'ANELLO 979628b02d 🐛 default last_report date is today, not the migration date 2020-08-06 20:30:17 +02:00
Yohann D'ANELLO bb8e3aaccf Display what is matched in user table (experimental, normalizing is not working) 2020-08-06 20:21:35 +02:00
Yohann D'ANELLO 86ff23357c Display what is matched in user table (experimental, normalizing is not working) 2020-08-06 20:19:29 +02:00
Yohann D'ANELLO fd2f426f55 🐛 Fix signup 2020-08-06 19:56:37 +02:00
Yohann D'ANELLO 48a7128370 📦 On a déménagé 2020-08-06 19:42:22 +02:00
Yohann D'ANELLO f222ba134d 🐛 Remove \eaddto in the invoice template, unicode characters weren't supported 2020-08-06 19:39:40 +02:00
Yohann D'ANELLO d95cd8c7c7 🎨 Better autocomplete field 2020-08-06 18:27:57 +02:00
Yohann D'ANELLO 5b3361f086 Product quantity must be positive 2020-08-06 18:05:58 +02:00
Yohann D'ANELLO 9c7cb07dec Improve activity interface 2020-08-06 17:41:30 +02:00
Yohann D'ANELLO dd4b24d999 Don't match users only with the start of the name 2020-08-06 15:21:16 +02:00
Yohann D'ANELLO eb3d426947 💩 Don't reset a transaction before saving it... 2020-08-06 15:18:14 +02:00
Yohann D'ANELLO d8c7018b9a FP is bugggy 2020-08-06 15:08:35 +02:00
Yohann D'ANELLO f47a0b8c9d Use neg for negative numbers in invoices 2020-08-06 14:41:14 +02:00
Yohann D'ANELLO c859fc7821 Use neg for negative numbers in invoices 2020-08-06 14:39:01 +02:00
Yohann D'ANELLO a4702fca86 Escape special TeX characters 2020-08-06 14:19:51 +02:00
Yohann D'ANELLO de5e0c958e Fix some activity errors 2020-08-06 14:11:55 +02:00
Yohann D'ANELLO 434a393f3b During the beta, don't update the wiki automatically 2020-08-06 13:09:35 +02:00
Yohann D'ANELLO cba6a35b6c Display matched alias in user table 2020-08-06 13:07:22 +02:00
Yohann D'ANELLO 0de69cbfaf 💚 Fix linters 2020-08-06 12:50:24 +02:00
Yohann D'ANELLO d9cf812074 🐛 Prevent transactions to have the same source and destination 2020-08-06 12:46:44 +02:00
Yohann D'ANELLO 252ddb832d 🐛 Invert membership date inequalities 2020-08-06 12:36:28 +02:00
Yohann D'ANELLO 6dcb82855d 🐛 Default comment is an empty string, not None 2020-08-06 12:31:57 +02:00
Yohann D'ANELLO 1247818033 🐛 lxml is required to parse html pages 2020-08-06 12:30:14 +02:00
Yohann D'ANELLO 9439b3cb2d 🐛 Add Beautifulsoup4 as a dependency 2020-08-06 12:22:19 +02:00
Yohann D'ANELLO a07b942738 Export activities in the Crans Wiki 2020-08-06 12:15:37 +02:00
Yohann D'ANELLO b7ae411f96 Export activities in the Crans Wiki 2020-08-06 12:15:23 +02:00
Yohann D'ANELLO 315af75c45 Backup database daily 2020-08-06 09:27:33 +02:00
Yohann D'ANELLO fd7e314ca3 Don't log mails in database 2020-08-06 08:53:47 +02:00
Yohann D'ANELLO 0b46140771 Cron file must be owned by root 2020-08-06 08:21:05 +02:00
Yohann D'ANELLO 547fbf564b Add cron to refresh highlighted buttons 2020-08-06 08:14:54 +02:00
Yohann D'ANELLO 2aebeb8927 respoinfo.bde => respo-info.bde 2020-08-06 08:13:44 +02:00
Yohann D'ANELLO 93f7e1d45b Fix check consistency script 2020-08-05 23:53:50 +02:00
Yohann D'ANELLO 199219861c Fix check consistency script 2020-08-05 23:51:59 +02:00
Yohann D'ANELLO 8b66bcc3d5 Fix cron 2020-08-05 23:50:30 +02:00
Yohann D'ANELLO 6759586ef3 Setup crons 2020-08-05 23:19:30 +02:00
Yohann D'ANELLO 33806967c8 Prepare weekly reports 2020-08-05 23:19:17 +02:00
Yohann D'ANELLO 24ac3ce45f Display users that have surnormal roles 2020-08-05 21:07:31 +02:00
Yohann D'ANELLO 018ca84e2d Prevent superusers when they make a transaction with a non-member user 2020-08-05 20:40:30 +02:00
Yohann D'ANELLO 2851d7764c Profile pictures are clickable 2020-08-05 19:52:36 +02:00
Yohann D'ANELLO c205219d47 🐛 Fix transaction update concurency 2020-08-05 19:42:44 +02:00
Yohann D'ANELLO b0398e59b8 🐛 Fix treasury 2020-08-05 18:04:01 +02:00
Yohann D'ANELLO 9c3e978a41 🐛 Fix signup 2020-08-05 16:27:44 +02:00
Yohann D'ANELLO 21f1347a60 🐛 Fix signup 2020-08-05 16:26:44 +02:00
Yohann D'ANELLO af857d6fae 🐛 Prevent transactions where note balances go out integer bounds 2020-08-05 16:23:32 +02:00
Yohann D'ANELLO acf7ecc4ae Use phone number validator 2020-08-05 14:14:51 +02:00
Yohann D'ANELLO 6c9cf73848 Update permissions to see our own note 2020-08-05 12:22:35 +02:00
Yohann D'ANELLO 2222175d4e Fix search transactions page 2020-08-05 11:34:23 +02:00
Yohann D'ANELLO a096dc4427 Adhere to parent clubs automatically, adhere to Kfet automatically when registering to the WEI 2020-08-04 20:04:41 +02:00
Yohann D'ANELLO 358691aaa9 🐛 Fix french translation 2020-08-03 23:57:59 +02:00
Yohann D'ANELLO 20ce817b16 🐛 WEI members must be members of the Kfet club *this year* 2020-08-03 23:55:01 +02:00
Yohann D'ANELLO ba067f050e Mails to be sent are added in a queue thanks to Django Mailer (todo: configure cron) 2020-08-03 20:09:16 +02:00
Yohann D'ANELLO 2a744a8610 Display invalid transactions but don't count on them in the total 2020-08-03 19:37:47 +02:00
Yohann D'ANELLO 0e8058ab0d Add script to send weekly report to all members 2020-08-03 19:35:25 +02:00
Yohann D'ANELLO 655390b265 A longer transaction history is better 2020-08-03 18:50:51 +02:00
Yohann D'ANELLO 985a5ca876 Add "search transactions page" 2020-08-03 18:49:15 +02:00
Yohann D'ANELLO 55580bc11e Merge remote-tracking branch 'origin/beta' into beta 2020-08-03 16:11:25 +02:00
Yohann D'ANELLO 5ea8d8f870 🎨 Update activity interface 2020-08-03 16:11:05 +02:00
Yohann D'ANELLO 0a2c9d9c87 🐛 Better entry page 2020-08-03 15:44:06 +02:00
Yohann D'ANELLO 208dc7f865 🎨 Use multiple checkboxes than multiple select widget 2020-08-03 13:33:25 +02:00
Yohann D'ANELLO fbf3a0bcf6 🐛 A new user can't take an existing alias as username 2020-08-03 12:35:51 +02:00
Yohann D'ANELLO 66defee3ea 🐛 Display the invalidity reason of an invalid transaction even if we can't validate it 2020-08-03 11:41:06 +02:00
Yohann D'ANELLO f8a4087e56 🐛 Display full registration table when the search bar is empty 2020-08-03 11:32:37 +02:00
Yohann D'ANELLO 94086505e6 Fix note balances 2020-08-03 11:16:01 +02:00
Yohann D'ANELLO 6c8843e5fc 🐛 Reset transfer form even if the note has not enough money 2020-08-03 10:54:52 +02:00
Yohann D'ANELLO 0e8174aacd 🐛 Fix objects with pk 0 2020-08-03 10:50:55 +02:00
Yohann D'ANELLO 0e3c4fcaf6 Warn users when a transaction has no source or no destination 2020-08-03 10:03:51 +02:00
Yohann D'ANELLO 58fe8914cf 🐛 Fix infinite loop in permission check 2020-08-02 22:39:30 +02:00
Yohann D'ANELLO f870af139e Typos 2020-08-02 09:51:39 +02:00
Yohann D'ANELLO 7742358b8f Secretaries can view and add memberships 2020-08-02 09:49:45 +02:00
Yohann D'ANELLO 8de7ba14bd Add permission for secretaries 2020-08-02 09:35:32 +02:00
Yohann D'ANELLO 8497dbb25c Club members can see the club 2020-08-02 09:30:18 +02:00
Yohann D'ANELLO f148c8dacb Better autocomplete field 2020-08-02 09:20:21 +02:00
Yohann D'ANELLO 2f018f8c9d Always query distinct objects 2020-08-02 08:57:16 +02:00
Yohann D'ANELLO 0ae61f3643 BDE memberships can start on 1st august 2020-08-02 08:47:23 +02:00
Yohann D'ANELLO b706efe463 2A+ can change their selected bus or team if the registration is not validated 2020-08-01 23:27:07 +02:00
Yohann D'ANELLO 37dc535d6d Display only one user 2020-08-01 23:05:14 +02:00
Yohann D'ANELLO 5ccbad8359 Fix transfer form reset 2020-08-01 23:03:10 +02:00
Yohann D'ANELLO c0cdb13130 Can't concatenate string and proxy 2020-08-01 22:30:34 +02:00
Yohann D'ANELLO 8434841ec5 Fix one permission 2020-08-01 22:28:28 +02:00
Yohann D'ANELLO cadf981013 passer en négatif -> être en négatif 2020-08-01 21:48:18 +02:00
Yohann D'ANELLO efc2b6b0b0 Send mail to users when the note balance is negative 2020-08-01 21:44:16 +02:00
529 changed files with 21109 additions and 46698 deletions

View File

@ -1,3 +1,5 @@
__pycache__ __pycache__
media media
db.sqlite3 db.sqlite3
.tox
.coverage

View File

@ -1,6 +1,6 @@
DJANGO_APP_STAGE=prod DJANGO_APP_STAGE=prod
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev # Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
DJANGO_DEV_STORE_METHOD=sqllite DJANGO_DEV_STORE_METHOD=sqlite
DJANGO_DB_HOST=localhost DJANGO_DB_HOST=localhost
DJANGO_DB_NAME=note_db DJANGO_DB_NAME=note_db
DJANGO_DB_USER=note DJANGO_DB_USER=note
@ -10,9 +10,15 @@ 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
EMAIL_HOST=smtp.localhost EMAIL_HOST=smtp.localhost
EMAIL_PORT=465 EMAIL_PORT=25
EMAIL_USER=notekfet@localhost EMAIL_USER=notekfet@localhost
EMAIL_PASSWORD=CHANGE_ME EMAIL_PASSWORD=CHANGE_ME
# Wiki configuration
WIKI_USER=NoteKfet2020
WIKI_PASSWORD=

7
.gitignore vendored
View File

@ -39,12 +39,11 @@ secrets.py
.env .env
map.json map.json
*.log *.log
media/ backups/
/static/
/media/
# Virtualenv # Virtualenv
env/ env/
venv/ venv/
db.sqlite3 db.sqlite3
# Ignore migrations during first phase dev
migrations/

View File

@ -1,30 +1,48 @@
image: python:3.8
stages: stages:
- test - test
- quality-assurance - quality-assurance
before_script: # Also fetch submodules
- pip install tox variables:
GIT_SUBMODULE_STRATEGY: recursive
py36-django22:
image: python:3.6
stage: test
script: tox -e py36-django22
# Debian Buster
py37-django22: py37-django22:
image: python:3.7
stage: test stage: test
image: debian:buster-backports
before_script:
- >
apt-get update &&
apt-get install --no-install-recommends -t buster-backports -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 py37-django22 script: tox -e py37-django22
# Ubuntu 20.04
py38-django22: py38-django22:
image: python:3.8
stage: test stage: test
image: ubuntu:20.04
before_script:
# Fix tzdata prompt
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
- >
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 py38-django22 script: tox -e py38-django22
linters: linters:
image: python:3.8
stage: quality-assurance stage: quality-assurance
image: debian:buster-backports
before_script:
- apt-get update && apt-get install -y tox
script: tox -e linters script: tox -e linters
# Be nice to new contributors, but please use `tox` # Be nice to new contributors, but please use `tox`

View File

@ -1,27 +1,27 @@
FROM python:3-alpine FROM debian:buster-backports
# Force the stdout and stderr streams to be unbuffered
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
# Install LaTeX requirements # Install Django, external apps, LaTeX and dependencies
RUN apk add --no-cache gettext texlive texmf-dist-latexextra texmf-dist-fontsextra nginx gcc libc-dev libffi-dev postgresql-dev libxml2-dev libxslt-dev jpeg-dev RUN apt-get update && \
apt-get install --no-install-recommends -t buster-backports -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 ipython3 \
python3-bs4 python3-setuptools \
uwsgi uwsgi-plugin-python3 \
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome && \
rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache bash # Instal PyPI requirements
COPY requirements.txt /var/www/note_kfet/
RUN pip3 install -r /var/www/note_kfet/requirements.txt --no-cache-dir
RUN mkdir /code # Copy code
WORKDIR /code WORKDIR /var/www/note_kfet
COPY requirements /code/requirements COPY . /var/www/note_kfet/
RUN pip install gunicorn ptpython --no-cache-dir
RUN pip install -r requirements/base.txt -r requirements/cas.txt -r requirements/production.txt --no-cache-dir
COPY . /code/ EXPOSE 8080
ENTRYPOINT ["/var/www/note_kfet/entrypoint.sh"]
# Configure nginx
RUN mkdir /run/nginx
RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log
RUN ln -sf /code/nginx_note.conf_docker /etc/nginx/conf.d/nginx_note.conf
RUN rm /etc/nginx/conf.d/default.conf
ENTRYPOINT ["/code/entrypoint.sh"]
EXPOSE 80
CMD ["./manage.py", "shell_plus", "--ptpython"]

674
LICENSE
View File

@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

278
README.md
View File

@ -1,72 +1,149 @@
# 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/nk20/commits/master) [![pipeline status](https://gitlab.crans.org/bde/nk20/badges/master/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master) [![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
## Installation sur un serveur ## Table des matières
On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout nu ou bien configuré. - [Installation d'une instance de développement](#installation-dune-instance-de-développement)
- [Installation d'une instance de production](#installation-dune-instance-de-production)
1. Paquets nécessaires ## Installation d'une instance de développement
$ sudo apt install nginx python3 python3-pip python3-dev uwsgi L'instance de développement installe la majorité des dépendances dans un environnement Python isolé.
$ sudo apt install uwsgi-plugin-python3 python3-venv git acl Bien que cela permette de créer une instance sur toutes les distributions,
**cela veut dire que vos dépendances ne seront pas mises à jour automatiquement.**
La génération des factures de l'application trésorerie nécessite une installation de LaTeX suffisante : 1. **Installation des dépendances de la distribution.**
Il y a quelques dépendances qui ne sont pas trouvable dans PyPI.
On donne ci-dessous l'exemple pour une distribution basée sur Debian, mais vous pouvez facilement adapter pour ArchLinux ou autre.
$ sudo apt install texlive-latex-extra texlive-fonts-extra texlive-lang-french ```bash
$ sudo apt update
$ sudo apt install --no-install-recommends -y \
ipython3 python3-setuptools python3-venv python3-dev \
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome git
```
2. Clonage du dépot 2. **Clonage du dépot** là où vous voulez :
on se met au bon endroit : ```bash
$ git clone git@gitlab.crans.org:bde/nk20.git --recursive && cd nk20
```
$ cd /var/www/ 3. **Création d'un environment de travail Python décorrélé du système.**
$ mkdir note_kfet On n'utilise pas `--system-site-packages` ici pour ne pas avoir des clashs de versions de modules avec le système.
$ sudo chown www-data:www-data note_kfet
$ sudo usermod -a -G www-data $USER
$ sudo chmod g+ws note_kfet
$ sudo setfacl -d -m "g::rwx" note_kfet
$ cd note_kfet
$ git clone git@gitlab.crans.org:bde/nk20.git .
3. Environment Virtuel
À la racine du projet:
```bash
$ python3 -m venv env $ python3 -m venv env
$ source env/bin/activate $ source env/bin/activate # entrer dans l'environnement
(env)$ pip3 install -r requirements/base.txt (env)$ pip3 install -r requirements.txt
(env)$ pip3 install -r requirements/prod.txt # uniquement en prod, nécessite un base postgres (env)$ deactivate # sortir de l'environnement
(env)$ deactivate ```
4. uwsgi et Nginx 4. **Variable d'environnement.**
Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
ce qu'il faut.
Un exemple de conf est disponible : 5. **Migrations et chargement des données initiales.**
Pour initialiser la base de données avec de quoi travailler.
```bash
(env)$ ./manage.py collectstatic --noinput
(env)$ ./manage.py compilemessages
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
(env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
```
6. Enjoy :
```bash
(env)$ ./manage.py runserver 0.0.0.0:8000
```
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
de la note sur un téléphone !
## Installation d'une instance de production
**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.
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.
0. Sous Debian Buster, **activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports.
```bash
$ echo "deb http://deb.debian.org/debian buster-backports main" | sudo tee /etc/apt/sources.list.d/deb_debian_org_debian.list
```
1. **Installation des dépendances APT.**
On tire les dépendances le plus possible à partir des dépôts de Debian.
On a besoin d'un environnement LaTeX pour générer les factures.
```bash
$ sudo apt update
$ sudo apt install --no-install-recommends -t buster-backports -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 ipython3 \
python3-bs4 python3-setuptools python3-docutils \
memcached uwsgi uwsgi-plugin-python3 \
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome \
nginx python3-venv git acl
```
2. **Clonage du dépot** dans `/var/www/note_kfet`,
```bash
$ sudo mkdir -p /var/www/note_kfet && cd /var/www/note_kfet
$ sudo chown www-data:www-data .
$ sudo chmod g+rwx .
$ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git --recursive
```
3. **Création d'un environment de travail Python décorrélé du système.**
```bash
$ python3 -m venv env --system-site-packages
$ source env/bin/activate # entrer dans l'environnement
(env)$ pip3 install -r requirements.txt
(env)$ deactivate # sortir de l'environnement
```
4. **Pour configurer UWSGI et NGINX**, des exemples de conf sont disponibles.
**_Modifier le fichier pour être en accord avec le reste de votre config_**
```bash
$ cp nginx_note.conf_example nginx_note.conf $ cp nginx_note.conf_example nginx_note.conf
***Modifier le fichier pour être en accord avec le reste de votre config***
On utilise uwsgi et Nginx pour gérer le coté serveur :
$ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/ $ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/
```
Si l'on a un emperor (plusieurs instance uwsgi): Si l'on a un emperor (plusieurs instance uwsgi):
```bash
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/ $ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/
```
Sinon: Sinon si on est dans le cas habituel :
```bash
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/apps-enabled/ $ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/apps-enabled/
```
Le touch-reload est activé par défault, pour redémarrer la note il suffit donc de faire `touch uwsgi_note.ini`. Le touch-reload est activé par défault, pour redémarrer la note il suffit donc de faire `touch uwsgi_note.ini`.
5. Base de données 5. **Base de données.** En production on utilise PostgreSQL.
En prod on utilise postgresql. $ sudo apt-get install postgresql postgresql-contrib
$ sudo apt-get install postgresql postgresql-contrib libpq-dev
(env)$ pip3 install psycopg2
La config de la base de donnée se fait comme suit: La config de la base de donnée se fait comme suit:
@ -107,7 +184,7 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
et on renseigne des secrets et des paramètres : et on renseigne des secrets et des paramètres :
DJANGO_APP_STAGE=dev # ou "prod" DJANGO_APP_STAGE=dev # ou "prod"
DJANGO_DEV_STORE_METHOD=sqllite # ou "postgres" DJANGO_DEV_STORE_METHOD=sqlite # ou "postgres"
DJANGO_DB_HOST=localhost DJANGO_DB_HOST=localhost
DJANGO_DB_NAME=note_db DJANGO_DB_NAME=note_db
DJANGO_DB_USER=note DJANGO_DB_USER=note
@ -115,98 +192,89 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
DJANGO_DB_PORT= DJANGO_DB_PORT=
DJANGO_SECRET_KEY=CHANGE_ME DJANGO_SECRET_KEY=CHANGE_ME
DJANGO_SETTINGS_MODULE="note_kfet.settings DJANGO_SETTINGS_MODULE="note_kfet.settings
DOMAIN=localhost # note.example.com
CONTACT_EMAIL=tresorerie.bde@localhost
NOTE_URL=localhost # URL où accéder à la note NOTE_URL=localhost # URL où accéder à la note
CONTACT_EMAIL=tresorerie.bde@localhost
# Le reste n'est utile qu'en production, pour configurer l'envoi des mails # Le reste n'est utile qu'en production, pour configurer l'envoi des mails
NOTE_MAIL=notekfet@localhost NOTE_MAIL=notekfet@localhost
EMAIL_HOST=smtp.localhost EMAIL_HOST=smtp.localhost
EMAIL_PORT=465 EMAIL_PORT=25
EMAIL_USER=notekfet@localhost EMAIL_USER=notekfet@localhost
EMAIL_PASSWORD=CHANGE_ME EMAIL_PASSWORD=CHANGE_ME
WIKI_USER=NoteKfet2020
WIKI_PASSWORD=CHANGE_ME
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
$ source /env/bin/activate $ source /env/bin/activate
(env)$ ./manage.py check # pas de bêtise qui traine (env)$ ./manage.py check # pas de bêtise qui traine
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate (env)$ ./manage.py migrate
7. Enjoy 7. *Enjoy \o/*
### Installation avec Docker
## Installer avec Docker
Il est possible de travailler sur une instance Docker. Il est possible de travailler sur une instance Docker.
1. Cloner le dépôt là où vous voulez : Pour construire l'image Docker `nk20`,
$ git clone git@gitlab.crans.org:bde/nk20.git ```
git clone https://gitlab.crans.org/bde/nk20/ --recursive && cd nk20
docker build . -t nk20
```
2. Copiez le fichier `.env_example` à la racine du projet vers le fichier `.env`, Ensuite pour lancer la note Kfet en tant que vous (option `-u`),
et mettez à jour vos variables d'environnement l'exposer sur son port 80 (option `-p`) et monter le code en écriture (option `-v`),
3. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré, ```
ajouter les lignes suivantes, en les adaptant à la configuration voulue : docker run -it --rm -u $(id -u):$(id -g) -v "$(pwd):/var/www/note_kfet/" -p 80:8080 nk20
```
nk20: Si vous souhaitez lancer une commande spéciale, vous pouvez l'ajouter à la fin, par exemple,
build: /chemin/vers/nk20
```
docker run -it --rm -u $(id -u):$(id -g) -v "$(pwd):/var/www/note_kfet/" -p 80:8080 nk20 python3 ./manage.py createsuperuser
```
#### Avec Docker Compose
On vous conseilles de faire un fichier d'environnement `.env` en prenant exemple sur `.env_example`.
Pour par exemple utiliser le Docker de la note Kfet avec Traefik pour réaliser le HTTPS,
```YAML
nk20:
build: /chemin/vers/le/code/nk20
volumes: volumes:
- /chemin/vers/nk20:/code/ - /chemin/vers/le/code/nk20:/var/www/note_kfet/
env_file: /chemin/vers/nk20/.env env_file: /chemin/vers/le/code/nk20/.env
restart: always restart: always
labels: labels:
- traefik.domain=ndd.example.com - "traefik.http.routers.nk20.rule=Host(`ndd.example.com`)"
- traefik.frontend.rule=Host:ndd.example.com - "traefik.http.services.nk20.loadbalancer.server.port=8080"
- traefik.port=8000 ```
3. Enjoy :
$ docker-compose up -d nk20
## Installer un serveur de développement
Avec `./manage.py runserver` il est très rapide de mettre en place
un serveur de développement par exemple sur son ordinateur.
1. Cloner le dépôt là où vous voulez :
$ git clone git@gitlab.crans.org:bde/nk20.git && cd nk20
2. Créer un environnement Python isolé
pour ne pas interférer avec les versions de paquets systèmes :
$ python3 -m venv venv
$ source venv/bin/activate
(env)$ pip install -r requirements/base.txt
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
ce qu'il faut
4. Migrations et chargement des données initiales :
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
5. Créer un super-utilisateur :
(env)$ ./manage.py createsuperuser
6. Enjoy :
(env)$ ./manage.py runserver 0.0.0.0:8000
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
de la note sur un téléphone !
## Cahier des Charges
Il est disponible [ici](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).
## Documentation ## Documentation
La documentation est générée par django et son module admindocs. Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).
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).
## FAQ
### 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.
```bash
django-admin makemessages -i env
```
Une fois les fichiers édités, vous pouvez compiler les nouvelles traductions avec
```bash
django-admin compilemessages
```

View File

@ -1,16 +1,18 @@
#!/usr/bin/env ansible-playbook #!/usr/bin/env ansible-playbook
--- ---
- hosts: bde-nk20-beta.adh.crans.org - hosts: all
vars_prompt: vars_prompt:
- name: DB_PASSWORD - name: DB_PASSWORD
prompt: "Password of the database" prompt: "Password of the database (leave it blank to skip database init)"
private: yes private: yes
vars:
mirror: deb.debian.org
roles: roles:
- 1-apt-basic - 1-apt-basic
- 2-nk20 - 2-nk20
- 3-pip - 3-pip
- 4-nginx - 4-certbot
- 5-certbot - 5-nginx
- 6-psql - 6-psql
- 7-postinstall - 7-postinstall

View File

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

View File

@ -0,0 +1,5 @@
---
note:
server_name: note.crans.org
git_branch: master
cron_enabled: true

View File

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

View File

@ -1,5 +1,9 @@
[server] [dev]
bde3-virt.adh.crans.org
bde-nk20-beta.adh.crans.org bde-nk20-beta.adh.crans.org
[prod]
bde-note.adh.crans.org
[all:vars] [all:vars]
ansible_python_interpreter=/usr/bin/python3 ansible_python_interpreter=/usr/bin/python3

View File

@ -1,21 +1,52 @@
--- ---
- name: Install basic APT packages - name: Add buster-backports to apt sources
apt_repository:
repo: deb http://{{ mirror }}/debian buster-backports main
state: present
- name: Install note_kfet APT dependencies
apt: apt:
update_cache: true update_cache: true
default_release: buster-backports
install_recommends: false
name: name:
- nginx # Common tools
- python3 - gettext
- git
- ipython3
# Front-end dependencies
- fonts-font-awesome
- libjs-bootstrap4
# Python dependencies
- python3-babel
- python3-bs4
- python3-django
- python3-django-crispy-forms
- python3-django-extensions
- python3-django-filters
- python3-django-oauth-toolkit
- python3-django-polymorphic
- python3-djangorestframework
- python3-lockfile
- python3-memcache
- python3-phonenumbers
- python3-pil
- python3-pip - python3-pip
- python3-dev - python3-psycopg2
- python3-setuptools
- python3-venv
# LaTeX (PDF generation)
- texlive-xetex
# Cache server
- memcached
# WSGI server
- uwsgi - uwsgi
- uwsgi-plugin-python3 - uwsgi-plugin-python3
- python3-venv
- git
- acl
- gettext
- texlive-latex-extra
- texlive-fonts-extra
- texlive-lang-french
register: pkg_result register: pkg_result
retries: 3 retries: 3
until: pkg_result is succeeded until: pkg_result is succeeded

View File

@ -11,7 +11,7 @@
git: git:
repo: https://gitlab.crans.org/bde/nk20.git repo: https://gitlab.crans.org/bde/nk20.git
dest: /var/www/note_kfet dest: /var/www/note_kfet
version: master version: "{{ note.git_branch }}"
force: true force: true
- name: Use default env vars (should be updated!) - name: Use default env vars (should be updated!)
@ -28,3 +28,11 @@
recurse: yes recurse: yes
owner: www-data owner: www-data
group: www-data group: www-data
- name: Setup cron jobs
when: "note.cron_enabled"
template:
src: note.cron.j2
dest: /etc/cron.d/note
owner: root
group: root

View File

@ -0,0 +1 @@
../../../../note.cron

View File

@ -1,14 +1,8 @@
--- ---
- name: Install PIP basic dependencies - name: Install PIP basic dependencies
pip: pip:
requirements: /var/www/note_kfet/requirements/base.txt requirements: /var/www/note_kfet/requirements.txt
virtualenv: /var/www/note_kfet/env
virtualenv_command: /usr/bin/python3 -m venv
become_user: www-data
- name: Install PIP production dependencies
pip:
requirements: /var/www/note_kfet/requirements/production.txt
virtualenv: /var/www/note_kfet/env virtualenv: /var/www/note_kfet/env
virtualenv_command: /usr/bin/python3 -m venv virtualenv_command: /usr/bin/python3 -m venv
virtualenv_site_packages: true
become_user: www-data become_user: www-data

View File

@ -1,4 +1,11 @@
--- ---
- name: Install NGINX
apt:
name: nginx
register: pkg_result
retries: 3
until: pkg_result is succeeded
- name: Copy conf of Nginx - name: Copy conf of Nginx
template: template:
src: "nginx_note.conf" src: "nginx_note.conf"

View File

@ -9,7 +9,7 @@ server {
listen [::]:80 default_server; listen [::]:80 default_server;
location / { location / {
return 301 https://nk20-beta.crans.org$request_uri; return 301 https://{{ note.server_name }}$request_uri;
} }
} }
@ -19,11 +19,11 @@ server {
listen [::]:443 ssl default_server; listen [::]:443 ssl default_server;
location / { location / {
return 301 https://nk20-beta.crans.org$request_uri; return 301 https://{{ note.server_name }}$request_uri;
} }
ssl_certificate /etc/letsencrypt/live/nk20-beta.crans.org/fullchain.pem; ssl_certificate /etc/letsencrypt/live/{{ note.server_name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nk20-beta.crans.org/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/{{ note.server_name }}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
} }
@ -35,7 +35,7 @@ server {
# the port your site will be served on # the port your site will be served on
# the domain name it will serve for # the domain name it will serve for
server_name nk20-beta.crans.org; # substitute your machine's IP address or FQDN server_name {{ note.server_name }}; # substitute your machine's IP address or FQDN
charset utf-8; charset utf-8;
# max upload size # max upload size
@ -53,11 +53,11 @@ server {
# 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;
include /var/www/note_kfet/uwsgi_params; # the uwsgi_params file you installed include /etc/nginx/uwsgi_params;
} }
ssl_certificate /etc/letsencrypt/live/nk20-beta.crans.org/fullchain.pem; ssl_certificate /etc/letsencrypt/live/{{ note.server_name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nk20-beta.crans.org/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/{{ note.server_name }}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
} }

View File

@ -10,17 +10,15 @@
retries: 3 retries: 3
until: pkg_result is succeeded until: pkg_result is succeeded
- name: Install Psycopg2
pip:
name: psycopg2-binary
- name: Create role note - name: Create role note
when: "DB_PASSWORD|bool" # 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"
postgresql_db: postgresql_db:
name: note_db name: note_db
owner: note owner: note

View File

@ -1,10 +1,4 @@
--- ---
- name: Make Django migrations
command: /var/www/note_kfet/env/bin/python manage.py makemigrations
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:
@ -22,3 +16,9 @@
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

@ -2,9 +2,10 @@
# 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
from note_kfet.admin import admin_site from note_kfet.admin import admin_site
from .models import Activity, ActivityType, Guest, Entry
from .forms import GuestForm
from .models import Activity, ActivityType, Entry, Guest
@admin.register(Activity, site=admin_site) @admin.register(Activity, site=admin_site)
@ -35,6 +36,7 @@ class GuestAdmin(admin.ModelAdmin):
Admin customisation for Guest Admin customisation for Guest
""" """
list_display = ('last_name', 'first_name', 'activity', 'inviter') list_display = ('last_name', 'first_name', 'activity', 'inviter')
form = GuestForm
@admin.register(Entry, site=admin_site) @admin.register(Entry, site=admin_site)

View File

@ -3,7 +3,7 @@
from rest_framework import serializers from rest_framework import serializers
from ..models import ActivityType, Activity, Guest, Entry, GuestTransaction from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction
class ActivityTypeSerializer(serializers.ModelSerializer): class ActivityTypeSerializer(serializers.ModelSerializer):

View File

@ -1,7 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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, GuestViewSet, EntryViewSet from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
def register_activity_urls(router, path): def register_activity_urls(router, path):

View File

@ -1,12 +1,12 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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 django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from api.viewsets import ReadProtectedModelViewSet
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer, EntrySerializer from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer
from ..models import ActivityType, Activity, Guest, Entry from ..models import Activity, ActivityType, Entry, Guest
class ActivityTypeViewSet(ReadProtectedModelViewSet): class ActivityTypeViewSet(ReadProtectedModelViewSet):

View File

@ -4,6 +4,7 @@
"pk": 1, "pk": 1,
"fields": { "fields": {
"name": "Pot", "name": "Pot",
"manage_entries": true,
"can_invite": true, "can_invite": true,
"guest_entry_fee": 500 "guest_entry_fee": 500
} }
@ -13,6 +14,17 @@
"pk": 2, "pk": 2,
"fields": { "fields": {
"name": "Soir\u00e9e de club", "name": "Soir\u00e9e de club",
"manage_entries": false,
"can_invite": false,
"guest_entry_fee": 0
}
},
{
"model": "activity.activitytype",
"pk": 3,
"fields": {
"name": "Autre",
"manage_entries": false,
"can_invite": false, "can_invite": false,
"guest_entry_fee": 0 "guest_entry_fee": 0
} }

View File

@ -1,18 +1,40 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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, datetime
from datetime import timedelta
from random import shuffle
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from member.models import Club from member.models import Club
from note.models import NoteUser, Note from note.models import Note, NoteUser
from note_kfet.inputs import DateTimePickerInput, Autocomplete from note_kfet.inputs import Autocomplete, DateTimePickerInput
from note_kfet.middlewares import get_current_authenticated_user
from permission.backends import PermissionBackend
from .models import Activity, Guest from .models import Activity, Guest
class ActivityForm(forms.ModelForm): class ActivityForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# By default, the Kfet club is attended
self.fields["attendees_club"].initial = Club.objects.get(name="Kfet")
self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet"
clubs = list(Club.objects.filter(PermissionBackend
.filter_queryset(get_current_authenticated_user(), Club, "view")).all())
shuffle(clubs)
self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
def clean_date_end(self):
date_end = self.cleaned_data["date_end"]
date_start = self.cleaned_data["date_start"]
if date_end < date_start:
self.add_error("date_end", _("The end date must be after the start date."))
return date_end
class Meta: class Meta:
model = Activity model = Activity
exclude = ('creater', 'valid', 'open', ) exclude = ('creater', 'valid', 'open', )
@ -39,9 +61,18 @@ class ActivityForm(forms.ModelForm):
class GuestForm(forms.ModelForm): class GuestForm(forms.ModelForm):
def clean(self): def clean(self):
"""
Someone can be invited as a Guest to an Activity if:
- the activity has not already started.
- the activity is validated.
- the Guest has not already been invited more than 5 times.
- the Guest is already invited.
- the inviter already invited 3 peoples.
"""
cleaned_data = super().clean() cleaned_data = super().clean()
if self.activity.date_start > datetime.now(): if timezone.now() > timezone.localtime(self.activity.date_start):
self.add_error("inviter", _("You can't invite someone once the activity is started.")) self.add_error("inviter", _("You can't invite someone once the activity is started."))
if not self.activity.valid: if not self.activity.valid:
@ -50,19 +81,19 @@ class GuestForm(forms.ModelForm):
one_year = timedelta(days=365) one_year = timedelta(days=365)
qs = Guest.objects.filter( qs = Guest.objects.filter(
first_name=cleaned_data["first_name"], first_name__iexact=cleaned_data["first_name"],
last_name=cleaned_data["last_name"], last_name__iexact=cleaned_data["last_name"],
activity__date_start__gte=self.activity.date_start - one_year, activity__date_start__gte=self.activity.date_start - one_year,
) )
if len(qs) >= 5: if qs.filter(entry__isnull=False).count() >= 5:
self.add_error("last_name", _("This person has been already invited 5 times this year.")) self.add_error("last_name", _("This person has been already invited 5 times this year."))
qs = qs.filter(activity=self.activity) qs = qs.filter(activity=self.activity)
if qs.exists(): if qs.exists():
self.add_error("last_name", _("This person is already invited.")) self.add_error("last_name", _("This person is already invited."))
qs = Guest.objects.filter(inviter=cleaned_data["inviter"], activity=self.activity) if "inviter" in cleaned_data:
if len(qs) >= 3: if Guest.objects.filter(inviter=cleaned_data["inviter"], activity=self.activity).count() >= 3:
self.add_error("inviter", _("You can't invite more than 3 people to this activity.")) self.add_error("inviter", _("You can't invite more than 3 people to this activity."))
return cleaned_data return cleaned_data

View File

@ -0,0 +1,69 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Activity',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('description', models.TextField(verbose_name='description')),
('location', models.CharField(blank=True, default='', help_text='Place where the activity is organized, eg. Kfet.', max_length=255, verbose_name='location')),
('date_start', models.DateTimeField(verbose_name='start date')),
('date_end', models.DateTimeField(verbose_name='end date')),
('valid', models.BooleanField(default=False, verbose_name='valid')),
('open', models.BooleanField(default=False, verbose_name='open')),
],
options={
'verbose_name': 'activity',
'verbose_name_plural': 'activities',
},
),
migrations.CreateModel(
name='ActivityType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('manage_entries', models.BooleanField(default=False, help_text='Enable the support of entries for this activity.', verbose_name='manage entries')),
('can_invite', models.BooleanField(default=False, verbose_name='can invite')),
('guest_entry_fee', models.PositiveIntegerField(default=0, verbose_name='guest entry fee')),
],
options={
'verbose_name': 'activity type',
'verbose_name_plural': 'activity types',
},
),
migrations.CreateModel(
name='Entry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='entry time')),
],
options={
'verbose_name': 'entry',
'verbose_name_plural': 'entries',
},
),
migrations.CreateModel(
name='Guest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_name', models.CharField(max_length=255, verbose_name='last name')),
('first_name', models.CharField(max_length=255, verbose_name='first name')),
],
options={
'verbose_name': 'guest',
'verbose_name_plural': 'guests',
},
),
]

View File

@ -0,0 +1,89 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('activity', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('member', '0001_initial'),
('note', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='GuestTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
('entry', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to='activity.Entry')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('note.transaction',),
),
migrations.AddField(
model_name='guest',
name='activity',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.Activity'),
),
migrations.AddField(
model_name='guest',
name='inviter',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='guests', to='note.NoteUser', verbose_name='inviter'),
),
migrations.AddField(
model_name='entry',
name='activity',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entries', to='activity.Activity', verbose_name='activity'),
),
migrations.AddField(
model_name='entry',
name='guest',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.PROTECT, to='activity.Guest'),
),
migrations.AddField(
model_name='entry',
name='note',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.NoteUser', verbose_name='note'),
),
migrations.AddField(
model_name='activity',
name='activity_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.ActivityType', verbose_name='type'),
),
migrations.AddField(
model_name='activity',
name='attendees_club',
field=models.ForeignKey(help_text='Club that is authorized to join the activity. Mostly the Kfet club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='attendees club'),
),
migrations.AddField(
model_name='activity',
name='creater',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='activity',
name='organizer',
field=models.ForeignKey(help_text='Club that organizes the activity. The entry fees will go to this club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='organizer'),
),
migrations.AlterUniqueTogether(
name='guest',
unique_together={('activity', 'last_name', 'first_name')},
),
migrations.AlterUniqueTogether(
name='entry',
unique_together={('activity', 'note', 'guest')},
),
migrations.AlterUniqueTogether(
name='activity',
unique_together={('name', 'date_start', 'date_end')},
),
]

View File

@ -1,14 +1,18 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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, datetime
import os
from datetime import timedelta
from threading import Thread
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 _
from rest_framework.exceptions import ValidationError
from note.models import NoteUser, Transaction from note.models import NoteUser, Transaction
from rest_framework.exceptions import ValidationError
class ActivityType(models.Model): class ActivityType(models.Model):
@ -24,11 +28,21 @@ class ActivityType(models.Model):
verbose_name=_('name'), verbose_name=_('name'),
max_length=255, max_length=255,
) )
manage_entries = models.BooleanField(
verbose_name=_('manage entries'),
help_text=_('Enable the support of entries for this activity.'),
default=False,
)
can_invite = models.BooleanField( can_invite = models.BooleanField(
verbose_name=_('can invite'), verbose_name=_('can invite'),
default=False,
) )
guest_entry_fee = models.PositiveIntegerField( guest_entry_fee = models.PositiveIntegerField(
verbose_name=_('guest entry fee'), verbose_name=_('guest entry fee'),
default=0,
) )
class Meta: class Meta:
@ -54,6 +68,14 @@ class Activity(models.Model):
verbose_name=_('description'), verbose_name=_('description'),
) )
location = models.CharField(
verbose_name=_('location'),
max_length=255,
blank=True,
default="",
help_text=_("Place where the activity is organized, eg. Kfet."),
)
activity_type = models.ForeignKey( activity_type = models.ForeignKey(
ActivityType, ActivityType,
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -72,6 +94,7 @@ class Activity(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
verbose_name=_('organizer'), verbose_name=_('organizer'),
help_text=_("Club that organizes the activity. The entry fees will go to this club."),
) )
attendees_club = models.ForeignKey( attendees_club = models.ForeignKey(
@ -79,6 +102,7 @@ class Activity(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
verbose_name=_('attendees club'), verbose_name=_('attendees club'),
help_text=_("Club that is authorized to join the activity. Mostly the Kfet club."),
) )
date_start = models.DateTimeField( date_start = models.DateTimeField(
@ -99,12 +123,34 @@ class Activity(models.Model):
verbose_name=_('open'), verbose_name=_('open'),
) )
@transaction.atomic
def save(self, *args, **kwargs):
"""
Update the activity wiki page each time the activity is updated (validation, change description, ...)
"""
if self.date_end < self.date_start:
raise ValidationError(_("The end date must be after the start date."))
ret = super().save(*args, **kwargs)
if not settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS:
def refresh_activities():
from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand
# Consider that we can update the wiki iff the WIKI_PASSWORD env var is not empty
RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name,
False, os.getenv("WIKI_PASSWORD"))
RefreshActivitiesCommand.refresh_raw_wiki_page("Modification de l'activité " + self.name,
False, os.getenv("WIKI_PASSWORD"))
Thread(daemon=True, target=refresh_activities).start()\
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities()
return ret
def __str__(self): def __str__(self):
return self.name return self.name
class Meta: class Meta:
verbose_name = _("activity") verbose_name = _("activity")
verbose_name_plural = _("activities") verbose_name_plural = _("activities")
unique_together = ("name", "date_start", "date_end",)
class Entry(models.Model): class Entry(models.Model):
@ -143,8 +189,14 @@ class Entry(models.Model):
verbose_name = _("entry") verbose_name = _("entry")
verbose_name_plural = _("entries") verbose_name_plural = _("entries")
def save(self, *args, **kwargs): def __str__(self):
return _("Entry for {guest}, invited by {note} to the activity {activity}").format(
guest=str(self.guest), note=str(self.note), activity=str(self.activity)) if self.guest \
else _("Entry for {note} to the activity {activity}").format(
guest=str(self.guest), note=str(self.note), activity=str(self.activity))
@transaction.atomic
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, ))
@ -209,22 +261,23 @@ 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)
if not force_insert: if not force_insert:
if self.activity.date_start > datetime.now(): if timezone.now() > timezone.localtime(self.activity.date_start):
raise ValidationError(_("You can't invite someone once the activity is started.")) raise ValidationError(_("You can't invite someone once the activity is started."))
if not self.activity.valid: if not self.activity.valid:
raise ValidationError(_("This activity is not validated yet.")) raise ValidationError(_("This activity is not validated yet."))
qs = Guest.objects.filter( qs = Guest.objects.filter(
first_name=self.first_name, first_name__iexact=self.first_name,
last_name=self.last_name, last_name__iexact=self.last_name,
activity__date_start__gte=self.activity.date_start - one_year, activity__date_start__gte=self.activity.date_start - one_year,
) )
if len(qs) >= 5: if qs.filter(entry__isnull=False).count() >= 5:
raise ValidationError(_("This person has been already invited 5 times this year.")) raise ValidationError(_("This person has been already invited 5 times this year."))
qs = qs.filter(activity=self.activity) qs = qs.filter(activity=self.activity)
@ -232,7 +285,7 @@ class Guest(models.Model):
raise ValidationError(_("This person is already invited.")) raise ValidationError(_("This person is already invited."))
qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity) qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity)
if len(qs) >= 3: if qs.count() >= 3:
raise ValidationError(_("You can't invite more than 3 people to this activity.")) raise ValidationError(_("You can't invite more than 3 people to this activity."))
return super().save(force_insert, force_update, using, update_fields) return super().save(force_insert, force_update, using, update_fields)

View File

@ -1,13 +1,13 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables
from django_tables2 import A from django_tables2 import A
from note.templatetags.pretty_money import pretty_money from note.templatetags.pretty_money import pretty_money
from .models import Activity, Guest, Entry from .models import Activity, Entry, Guest
class ActivityTable(tables.Table): class ActivityTable(tables.Table):
@ -20,6 +20,11 @@ class ActivityTable(tables.Table):
attrs = { attrs = {
'class': 'table table-condensed table-striped table-hover' 'class': 'table table-condensed table-striped table-hover'
} }
row_attrs = {
'class': lambda record: 'bg-success' if record.open else ('' if record.valid else 'bg-warning'),
'title': lambda record: _("The activity is currently open.") if record.open else
('' if record.valid else _("The validation of the activity is pending.")),
}
model = Activity model = Activity
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'activity_type', 'organizer', 'attendees_club', 'date_start', 'date_end', ) fields = ('name', 'activity_type', 'organizer', 'attendees_club', 'date_start', 'date_end', )
@ -28,22 +33,17 @@ class ActivityTable(tables.Table):
class GuestTable(tables.Table): class GuestTable(tables.Table):
inviter = tables.LinkColumn( inviter = tables.LinkColumn(
'member:user_detail', 'member:user_detail',
args=[A('inviter.user.pk'), ], args=[A('inviter__user__pk'), ],
) )
entry = tables.Column( entry = tables.Column(
empty_values=(), empty_values=(),
attrs={ verbose_name=_("Remove"),
"td": {
"class": lambda record: "" if record.has_entry else "validate btn btn-danger",
"onclick": lambda record: "" if record.has_entry else "remove_guest(" + str(record.pk) + ")"
}
}
) )
class Meta: class Meta:
attrs = { attrs = {
'class': 'table table-condensed table-striped table-hover' 'class': 'table table-condensed table-striped'
} }
model = Guest model = Guest
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
@ -52,7 +52,8 @@ 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 _("remove").capitalize() return format_html('<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()))
def get_row_class(record): def get_row_class(record):
@ -66,6 +67,10 @@ def get_row_class(record):
qs = Entry.objects.filter(note=record.note, activity=record.activity, guest=None) qs = Entry.objects.filter(note=record.note, activity=record.activity, guest=None)
if qs.exists(): if qs.exists():
c += " table-success" c += " table-success"
elif not record.note.user.memberships.filter(club=record.activity.attendees_club,
date_start__lte=timezone.now(),
date_end__gte=timezone.now()).exists():
c += " table-info"
elif record.note.balance < 0: elif record.note.balance < 0:
c += " table-danger" c += " table-danger"
return c return c

View File

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{% load render_table from django_tables2 %}
{% block content %}
<h1 class="text-white">{{ title }}</h1>
{% include "activity/includes/activity_info.html" %}
{% if guests.data %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{% trans "Guests list" %}
</h3>
<div id="guests_table">
{% render_table guests %}
</div>
</div>
{% endif %}
{% endblock %}
{% block extrajavascript %}
<script>
function remove_guest(guest_id) {
$.ajax({
url:"/api/activity/guest/" + guest_id + "/",
method:"DELETE",
headers: {"X-CSRFTOKEN": CSRF_TOKEN}
})
.done(function() {
addMsg('Invité supprimé','success');
$("#guests_table").load(location.pathname + " #guests_table");
})
.fail(function(xhr, textStatus, error) {
errMsg(xhr.responseJSON);
});
}
$("#open_activity").click(function() {
$.ajax({
url: "/api/activity/activity/{{ activity.pk }}/",
type: "PATCH",
dataType: "json",
headers: {
"X-CSRFTOKEN": CSRF_TOKEN
},
data: {
open: {{ activity.open|yesno:'false,true' }}
}
}).done(function () {
reloadWithTurbolinks();
}).fail(function (xhr) {
errMsg(xhr.responseJSON);
});
});
$("#validate_activity").click(function () {
$.ajax({
url: "/api/activity/activity/{{ activity.pk }}/",
type: "PATCH",
dataType: "json",
headers: {
"X-CSRFTOKEN": CSRF_TOKEN
},
data: {
valid: {{ activity.valid|yesno:'false,true' }}
}
}).done(function () {
reloadWithTurbolinks();
}).fail(function (xhr) {
errMsg(xhr.responseJSON);
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,166 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load static i18n pretty_money perms %}
{% load render_table from django_tables2 %}
{% block content %}
<h1 class="text-white">{{ title }}</h1>
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle bg-light" style="width: 100%">
<a href="{% url "note:transfer" %}#transfer" class="btn btn-sm btn-outline-primary">
{% trans "Transfer" %}
</a>
{% if "note.notespecial"|not_empty_model_list %}
<a href="{% url "note:transfer" %}#credit" class="btn btn-sm btn-outline-primary">
{% trans "Credit" %}
</a>
<a href="{% url "note:transfer" %}#debit" class="btn btn-sm btn-outline-primary">
{% trans "Debit" %}
</a>
{% endif %}
{% for a in activities_open %}
<a href="{% url "activity:activity_entry" pk=a.pk %}"
class="btn btn-sm btn-outline-primary{% if a.pk == activity.pk %} active{% endif %}">
{% trans "Entries" %} {{ a.name }}
</a>
{% endfor %}
</div>
</div>
</div>
<hr>
<a href="{% url "activity:activity_detail" pk=activity.pk %}">
<button class="btn btn-light">{% trans "Return to activity page" %}</button>
</a>
<input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
<hr>
<div class="card" id="entry_table">
<h2 class="text-center">{{ entries.count }}
{% if entries.count >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %}</h2>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script>
old_pattern = null;
alias_obj = $("#alias");
function reloadTable(force = false) {
let pattern = alias_obj.val();
if ((pattern === old_pattern || pattern === "") && !force)
return;
$("#entry_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + " #entry_table", init);
refreshBalance();
}
alias_obj.keyup(reloadTable);
$(document).ready(init);
function init() {
$(".table-row").click(function (e) {
let target = e.target.parentElement;
target = $("#" + target.id);
let type = target.attr("data-type");
let id = target.attr("data-id");
let last_name = target.attr("data-last-name");
let first_name = target.attr("data-first-name");
if (type === "membership") {
$.post("/api/activity/entry/?format=json", {
csrfmiddlewaretoken: CSRF_TOKEN,
activity: {{ activity.id }},
note: id,
guest: null
}).done(function () {
if (target.hasClass("table-info"))
addMsg(
"Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.",
"warning", 10000);
else
addMsg("Entrée effectuée !", "success", 4000);
reloadTable(true);
}).fail(function (xhr) {
errMsg(xhr.responseJSON, 4000);
});
} else {
let line_obj = $("#buttons_guest_" + id);
if (line_obj.length || target.attr('class').includes("table-success")) {
line_obj.remove();
return;
}
let tr = "<tr class='text-center'>" +
"<td id='buttons_guest_" + id + "' style='table-danger center' colspan='5'>" +
"<button id='transaction_guest_" + id +
"' class='btn btn-secondary'>Payer avec la note de l'hôte</button> " +
"<button id='transaction_guest_" + id +
"_especes' class='btn btn-secondary'>Payer en espèces</button> " +
"<button id='transaction_guest_" + id +
"_cb' class='btn btn-secondary'>Payer en CB</button></td>" +
"<tr>";
$(tr).insertAfter(target);
let makeTransaction = function () {
$.post("/api/activity/entry/?format=json", {
csrfmiddlewaretoken: CSRF_TOKEN,
activity: {{ activity.id }},
note: target.attr("data-inviter"),
guest: id
}).done(function () {
if (target.hasClass("table-info"))
addMsg(
"Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.",
"warning", 10000);
else
addMsg("Entrée effectuée !", "success", 4000);
reloadTable(true);
}).fail(function (xhr) {
errMsg(xhr.responseJSON, 4000);
});
};
let credit = function (credit_id, credit_name) {
return function () {
$.post("/api/note/transaction/transaction/", {
"csrfmiddlewaretoken": CSRF_TOKEN,
"quantity": 1,
"amount": {{ activity.activity_type.guest_entry_fee }},
"reason": "Crédit " + credit_name +
" (invitation {{ activity.name }})",
"valid": true,
"polymorphic_ctype": {{ notespecial_ctype }},
"resourcetype": "SpecialTransaction",
"source": credit_id,
"destination": target.attr('data-inviter'),
"last_name": last_name,
"first_name": first_name,
"bank": ""
}).done(function () {
makeTransaction();
reset();
}).fail(function (xhr) {
errMsg(xhr.responseJSON, 4000);
});
};
};
$("#transaction_guest_" + id).click(makeTransaction);
$("#transaction_guest_" + id + "_especes").click(credit(1, "espèces"));
$("#transaction_guest_" + id + "_cb").click(credit(2, "carte bancaire"));
}
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
{% if started_activities %}
<div class="card bg-secondary text-white mb-3">
<h3 class="card-header text-center">
{% trans "Current activity" %}
</h3>
<div class="card-body text-dark">
{% for activity in started_activities %}
{% include "activity/includes/activity_info.html" %}
{% endfor %}
</div>
</div>
{% endif %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Upcoming activities" %}
</h3>
{% if upcoming.data %}
{% render_table upcoming %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no planned activity." %}
</div>
</div>
{% endif %}
<div class="card-footer">
<a class="btn btn-sm btn-success" href="{% url 'activity:activity_create' %}" data-turbolinks="false">
<i class="fa fa-calendar-plus-o" aria-hidden="true"></i>
{% trans 'New activity' %}
</a>
</div>
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "All activities" %}
</h3>
{% render_table table %}
</div>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms pretty_money %}
{% url 'activity:activity_detail' activity.pk as activity_detail_url %}
<div id="activity_info" class="card bg-light shadow mb-3">
<div class="card-header text-center">
<h4>
{% if request.path_info != activity_detail_url %}
<a href="{{ activity_detail_url }}">{{ activity.name }}</a>
{% else %}
{{ activity.name }}
{% endif %}
</h4>
</div>
<div class="card-body" id="profile_infos">
<dl class="row">
<dt class="col-xl-6">{% trans 'description'|capfirst %}</dt>
<dd class="col-xl-6"> {{ activity.description|linebreaks }}</dd>
<dt class="col-xl-6">{% trans 'type'|capfirst %}</dt>
<dd class="col-xl-6"> {{ activity.activity_type }}</dd>
<dt class="col-xl-6">{% trans 'start date'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.date_start }}</dd>
<dt class="col-xl-6">{% trans 'end date'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.date_end }}</dd>
{% if "activity.change_activity_valid"|has_perm:activity %}
<dt class="col-xl-6">{% trans 'creater'|capfirst %}</dt>
<dd class="col-xl-6"><a href="{% url "member:user_detail" pk=activity.creater.pk %}">{{ activity.creater }}</a></dd>
{% endif %}
<dt class="col-xl-6">{% trans 'organizer'|capfirst %}</dt>
<dd class="col-xl-6"><a href="{% url "member:club_detail" pk=activity.organizer.pk %}">{{ activity.organizer }}</a></dd>
<dt class="col-xl-6">{% trans 'attendees club'|capfirst %}</dt>
<dd class="col-xl-6"><a href="{% url "member:club_detail" pk=activity.attendees_club.pk %}">{{ activity.attendees_club }}</a></dd>
<dt class="col-xl-6">{% trans 'can invite'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.activity_type.can_invite|yesno }}</dd>
{% if activity.activity_type.can_invite %}
<dt class="col-xl-6">{% trans 'guest entry fee'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.activity_type.guest_entry_fee|pretty_money }}</dd>
{% endif %}
<dt class="col-xl-6">{% trans 'valid'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.valid|yesno }}</dd>
<dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.open|yesno }}</dd>
</dl>
</div>
<div class="card-footer text-center">
{% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %}
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
{% endif %}
{% if request.path_info == activity_detail_url %}
{% if activity.valid and ".change__open"|has_perm:activity %}
<a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a>
{% endif %}
{% if not activity.open and ".change__valid"|has_perm:activity %}
<a class="btn btn-success btn-sm my-1" id="validate_activity"> {% if activity.valid %}{% trans "invalidate"|capfirst %}{% else %}{% trans "validate"|capfirst %}{% endif %}</a>
{% endif %}
{% if ".change_"|has_perm:activity %}
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_update' pk=activity.pk %}" data-turbolinks="false"> {% trans "edit"|capfirst %}</a>
{% endif %}
{% if activity.activity_type.can_invite and not activity_started %}
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_invite' pk=activity.pk %}" data-turbolinks="false"> {% trans "Invite" %}</a>
{% endif %}
{% endif %}
</div>
</div>

View File

@ -0,0 +1,175 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from activity.models import Activity, ActivityType, Guest, Entry
from member.models import Club
class TestActivities(TestCase):
"""
Test activities
"""
fixtures = ('initial',)
def setUp(self):
self.user = User.objects.create_superuser(
username="admintoto",
password="tototototo",
email="toto@example.com"
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
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",
)
def test_activity_list(self):
"""
Display the list of all activities
"""
response = self.client.get(reverse("activity:activity_list"))
self.assertEqual(response.status_code, 200)
def test_activity_create(self):
"""
Create a new activity
"""
response = self.client.get(reverse("activity:activity_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("activity:activity_create"), data=dict(
name="Activity created",
description="This activity was successfully created.",
location="Earth",
activity_type=ActivityType.objects.get(name="Soirée de club").id,
creater=self.user.id,
organizer=Club.objects.get(name="Kfet").id,
attendees_club=Club.objects.get(name="Kfet").id,
date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
valid=True,
))
self.assertTrue(Activity.objects.filter(name="Activity created").exists())
activity = Activity.objects.get(name="Activity created")
self.assertRedirects(response, reverse("activity:activity_detail", args=(activity.pk,)), 302, 200)
def test_activity_detail(self):
"""
Display the detail of an activity
"""
response = self.client.get(reverse("activity:activity_detail", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
def test_activity_update(self):
"""
Update an activity
"""
response = self.client.get(reverse("activity:activity_update", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("activity:activity_update", args=(self.activity.pk,)), data=dict(
name=str(self.activity) + " updated",
description="This activity was successfully updated.",
location="Earth",
activity_type=ActivityType.objects.get(name="Autre").id,
creater=self.user.id,
organizer=Club.objects.get(name="Kfet").id,
attendees_club=Club.objects.get(name="Kfet").id,
date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
valid=True,
))
self.assertTrue(Activity.objects.filter(name="Activity updated").exists())
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
def test_activity_entry(self):
"""
Create some entries
"""
self.activity.open = True
self.activity.save()
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=guest")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=admin")
self.assertEqual(response.status_code, 200)
# User entry
response = self.client.post("/api/activity/entry/", data=dict(
activity=self.activity.id,
note=self.user.note.id,
guest="",
))
self.assertEqual(response.status_code, 201) # 201 = Created
self.assertTrue(Entry.objects.filter(note=self.user.note, guest=None, activity=self.activity).exists())
# Guest entry
response = self.client.post("/api/activity/entry/", data=dict(
activity=self.activity.id,
note=self.user.note.id,
guest=self.guest.id,
))
self.assertEqual(response.status_code, 201) # 201 = Created
self.assertTrue(Entry.objects.filter(note=self.user.note, guest=self.guest.id, activity=self.activity).exists())
def test_activity_invite(self):
"""
Try to invite people to an activity
"""
response = self.client.get(reverse("activity:activity_invite", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
# The activity is started, can't invite
response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
activity=self.activity.id,
inviter=self.user.note.id,
last_name="GUEST2",
first_name="Guest",
))
self.assertEqual(response.status_code, 200)
self.activity.date_start += timedelta(days=1)
self.activity.save()
response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
activity=self.activity.id,
inviter=self.user.note.id,
last_name="GUEST2",
first_name="Guest",
))
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
def test_activity_ics(self):
"""
Render the ICS calendar
"""
response = self.client.get(reverse("activity:calendar_ics"))
self.assertEqual(response.status_code, 200)

View File

@ -14,4 +14,5 @@ urlpatterns = [
path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'), path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'), path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
path('new/', views.ActivityCreateView.as_view(), name='activity_create'), path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'),
] ]

View File

@ -1,30 +1,53 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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, timezone from hashlib import md5
from django.conf import settings 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.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.http import HttpResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView 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.decorators.cache import cache_page
from django.views.generic import DetailView, TemplateView, UpdateView
from django_tables2.views import SingleTableView from django_tables2.views import SingleTableView
from note.models import NoteUser, Alias, NoteSpecial from note.models import Alias, NoteSpecial, NoteUser
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ActivityForm, GuestForm from .forms import ActivityForm, GuestForm
from .models import Activity, Guest, Entry from .models import Activity, Entry, Guest
from .tables import ActivityTable, GuestTable, EntryTable from .tables import ActivityTable, EntryTable, GuestTable
class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
View to create a new Activity
"""
model = Activity model = Activity
form_class = ActivityForm form_class = ActivityForm
extra_context = {"title": _("Create new activity")} extra_context = {"title": _("Create new activity")}
def get_sample_object(self):
return Activity(
name="",
description="",
creater=self.request.user,
activity_type_id=1,
organizer_id=1,
attendees_club_id=1,
date_start=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)
@ -35,6 +58,9 @@ class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
Displays all Activities, and classify if they are on-going or upcoming ones.
"""
model = Activity model = Activity
table_class = ActivityTable table_class = ActivityTable
ordering = ('-date_start',) ordering = ('-date_start',)
@ -46,16 +72,24 @@ class ActivityListView(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)
upcoming_activities = Activity.objects.filter(date_end__gt=datetime.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.user, Activity, "view")),
prefix='upcoming-', prefix='upcoming-',
) )
started_activities = Activity.objects\
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.filter(open=True, valid=True).all()
context["started_activities"] = started_activities
return context return context
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Shows details about one activity. Add guest to context
"""
model = Activity model = Activity
context_object_name = "activity" context_object_name = "activity"
extra_context = {"title": _("Activity detail")} extra_context = {"title": _("Activity detail")}
@ -67,12 +101,15 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
context["guests"] = table context["guests"] = table
context["activity_started"] = datetime.now(timezone.utc) > self.object.date_start context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
return context return context
class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Updates one Activity
"""
model = Activity model = Activity
form_class = ActivityForm form_class = ActivityForm
extra_context = {"title": _("Update activity")} extra_context = {"title": _("Update activity")}
@ -81,10 +118,23 @@ class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`
"""
model = Guest model = Guest
form_class = GuestForm form_class = GuestForm
template_name = "activity/activity_invite.html" template_name = "activity/activity_form.html"
def get_sample_object(self):
""" Creates a standart Guest binds to the Activity"""
activity = Activity.objects.get(pk=self.kwargs["pk"])
return Guest(
activity=activity,
first_name="",
last_name="",
inviter=self.request.user.note,
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -96,8 +146,10 @@ class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
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.user, Activity, "view"))\
.get(pk=self.kwargs["pk"]) .get(pk=self.kwargs["pk"])
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.user, Activity, "view")).get(pk=self.kwargs["pk"])
@ -108,57 +160,113 @@ class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
class ActivityEntryView(LoginRequiredMixin, TemplateView): class ActivityEntryView(LoginRequiredMixin, TemplateView):
"""
Manages entry to an activity
"""
template_name = "activity/activity_entry.html" template_name = "activity/activity_entry.html"
def get_context_data(self, **kwargs): def dispatch(self, request, *args, **kwargs):
context = super().get_context_data(**kwargs) """
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.
"""
activity = Activity.objects.get(pk=self.kwargs["pk"])
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ sample_entry = Entry(activity=activity, note=self.request.user.note)
.get(pk=self.kwargs["pk"]) if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry):
context["activity"] = activity raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
matched = [] if not activity.activity_type.manage_entries:
raise PermissionDenied(_("This activity does not support activity entries."))
pattern = "^$" if not activity.open:
if "search" in self.request.GET: raise PermissionDenied(_("This activity is closed."))
pattern = self.request.GET["search"] return super().dispatch(request, *args, **kwargs)
if not pattern: def get_invited_guest(self, activity):
pattern = "^$" """
Retrieves all Guests to the activity
if pattern[0] != "^": """
pattern = "^" + pattern
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(Q(first_name__regex=pattern) | Q(last_name__regex=pattern) .filter(activity=activity)\
| Q(inviter__alias__name__regex=pattern)
| Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern))) \
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
.distinct()[:20] .order_by('last_name', 'first_name').distinct()
for guest in guest_qs:
guest.type = "Invité"
matched.append(guest)
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
if pattern[0] != "^":
pattern = "^" + pattern
guest_qs = guest_qs.filter(
Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern)
| Q(inviter__alias__name__iregex=pattern)
| Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern))
)
else:
guest_qs = guest_qs.none()
return guest_qs
def get_invited_note(self, activity):
"""
Retrieves all Note that can attend the activity,
they need to have an up-to-date membership in the attendees_club.
"""
note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"), note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"),
first_name=F("note__noteuser__user__first_name"), first_name=F("note__noteuser__user__first_name"),
username=F("note__noteuser__user__username"), username=F("note__noteuser__user__username"),
note_name=F("name"), note_name=F("name"),
balance=F("note__balance"))\ balance=F("note__balance"))
.filter(Q(note__polymorphic_ctype__model="noteuser")
& (Q(note__noteuser__user__first_name__regex=pattern) # Keep only users that have a note
| Q(note__noteuser__user__last_name__regex=pattern) note_qs = note_qs.filter(note__noteuser__isnull=False)
| Q(name__regex=pattern)
| Q(normalized_name__regex=Alias.normalize(pattern)))) \ # Keep only members
.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) note_qs = note_qs.filter(
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql_psycopg2': note__noteuser__user__memberships__club=activity.attendees_club,
note_qs = note_qs.distinct('note__pk')[:20] note__noteuser__user__memberships__date_start__lte=timezone.now(),
note__noteuser__user__memberships__date_end__gte=timezone.now(),
)
# Filter with permission backend
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
note_qs = note_qs.filter(
Q(note__noteuser__user__first_name__iregex=pattern)
| Q(note__noteuser__user__last_name__iregex=pattern)
| Q(name__iregex=pattern)
| Q(normalized_name__iregex=Alias.normalize(pattern))
)
else: else:
note_qs = note_qs.none()
# SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
# have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
# In production mode, please use PostgreSQL. # In production mode, please use PostgreSQL.
note_qs = note_qs.distinct()[:20] note_qs = note_qs.distinct('note__pk')[:20]\
for note in note_qs: if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
return note_qs
def get_context_data(self, **kwargs):
"""
Query the list of Guest and Note to the activity and add information to makes entry with JS.
"""
context = super().get_context_data(**kwargs)
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity
matched = []
for guest in self.get_invited_guest(activity):
guest.type = "Invité"
matched.append(guest)
for note in self.get_invited_note(activity):
note.type = "Adhérent" note.type = "Adhérent"
note.activity = activity note.activity = activity
matched.append(note) matched.append(note)
@ -172,8 +280,70 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
context["activities_open"] = Activity.objects.filter(open=True).filter( activities_open = Activity.objects.filter(open=True).filter(
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).filter( PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all()
PermissionBackend.filter_queryset(self.request.user, Activity, "change")).all() context["activities_open"] = [a for a in activities_open
if PermissionBackend.check_perm(self.request.user,
"activity.add_entry",
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):
"""
Render an ICS calendar with all valid activities.
"""
def multilines(self, string, maxlength, offset=0):
newstring = string[:maxlength - offset]
string = string[maxlength - offset:]
while string:
newstring += "\r\n "
newstring += string[:maxlength - 1]
string = string[maxlength - 1:]
return newstring
def get(self, request, *args, **kwargs):
ics = """BEGIN:VCALENDAR
VERSION: 2.0
PRODID:Note Kfet 2020
X-WR-CALNAME:Kfet Calendar
NAME:Kfet Calendar
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
"""
for activity in Activity.objects.filter(valid=True).order_by("-date_start").all():
ics += f"""BEGIN:VEVENT
DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z
UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}
DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)}
LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + """
-- {activity.organizer.name}
END:VEVENT
"""
ics += "END:VCALENDAR"
ics = ics.replace("\r", "").replace("\n", "\r\n")
return HttpResponse(ics, content_type="text/calendar; charset=UTF-8")

33
apps/api/serializers.py Normal file
View File

@ -0,0 +1,33 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User
from rest_framework.serializers import ModelSerializer
class UserSerializer(ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = User
exclude = (
'password',
'groups',
'user_permissions',
)
class ContentTypeSerializer(ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = ContentType
fields = '__all__'

View File

@ -1,85 +1,45 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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.urls import url, include from django.conf.urls import url, include
from django.contrib.auth.models import User from rest_framework import routers
from django.contrib.contenttypes.models import ContentType
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import routers, serializers
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet
from activity.api.urls import register_activity_urls
from api.viewsets import ReadProtectedModelViewSet
from member.api.urls import register_members_urls
from note.api.urls import register_note_urls
from treasury.api.urls import register_treasury_urls
from logs.api.urls import register_logs_urls
from permission.api.urls import register_permission_urls
from wei.api.urls import register_wei_urls
class UserSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = User
exclude = (
'password',
'groups',
'user_permissions',
)
class ContentTypeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = ContentType
fields = '__all__'
class UserViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
search_fields = ['$username', '$first_name', '$last_name', ]
# This ViewSet is the only one that is accessible from all authenticated users!
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = ContentType.objects.all()
serializer_class = ContentTypeSerializer
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.
# Register each app API router and user viewset # Register each app API router and user viewset
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register('models', ContentTypeViewSet) router.register('models', ContentTypeViewSet)
router.register('user', UserViewSet) router.register('user', UserViewSet)
register_members_urls(router, 'members')
register_activity_urls(router, 'activity') if "member" in settings.INSTALLED_APPS:
register_note_urls(router, 'note') from member.api.urls import register_members_urls
register_treasury_urls(router, 'treasury') register_members_urls(router, 'members')
register_permission_urls(router, 'permission')
register_logs_urls(router, 'logs') if "member" in settings.INSTALLED_APPS:
register_wei_urls(router, 'wei') from activity.api.urls import register_activity_urls
register_activity_urls(router, 'activity')
if "note" in settings.INSTALLED_APPS:
from note.api.urls import register_note_urls
register_note_urls(router, 'note')
if "treasury" in settings.INSTALLED_APPS:
from treasury.api.urls import register_treasury_urls
register_treasury_urls(router, 'treasury')
if "permission" in settings.INSTALLED_APPS:
from permission.api.urls import register_permission_urls
register_permission_urls(router, 'permission')
if "logs" in settings.INSTALLED_APPS:
from logs.api.urls import register_logs_urls
register_logs_urls(router, 'logs')
if "wei" in settings.INSTALLED_APPS:
from wei.api.urls import register_wei_urls
register_wei_urls(router, 'wei')
app_name = 'api' app_name = 'api'

View File

@ -2,12 +2,19 @@
# 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_filters.rest_framework import DjangoFilterBackend
from django.db.models import Q
from django.conf import settings
from django.contrib.auth.models import User
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from rest_framework import viewsets from note_kfet.middlewares import get_current_session
from note_kfet.middlewares import get_current_authenticated_user from note.models import Alias
from .serializers import UserSerializer, ContentTypeSerializer
class ReadProtectedModelViewSet(viewsets.ModelViewSet): class ReadProtectedModelViewSet(ModelViewSet):
""" """
Protect a ModelViewSet by filtering the objects that the user cannot see. Protect a ModelViewSet by filtering the objects that the user cannot see.
""" """
@ -17,11 +24,12 @@ class ReadProtectedModelViewSet(viewsets.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 = get_current_authenticated_user() user = self.request.user
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")) get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet): class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
""" """
Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see. Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see.
""" """
@ -31,5 +39,74 @@ class ReadOnlyProtectedModelViewSet(viewsets.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 = get_current_authenticated_user() user = self.request.user
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")) get_current_session().setdefault("permission_mask", 42)
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class UserViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
def get_queryset(self):
queryset = super().get_queryset()
# Sqlite doesn't support ORDER BY in subqueries
queryset = queryset.order_by("username") \
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
if "search" in self.request.GET:
pattern = self.request.GET["search"]
# Filter with different rules
# We use union-all to keep each filter rule sorted in result
queryset = queryset.filter(
# Match without normalization
note__alias__name__iregex="^" + pattern
).union(
queryset.filter(
# Match with normalization
Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
),
all=True,
).union(
queryset.filter(
# Match on lower pattern
Q(note__alias__normalized_name__iregex="^" + pattern.lower())
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
),
all=True,
).union(
queryset.filter(
# Match on firstname or lastname
(Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern))
& ~Q(note__alias__normalized_name__iregex="^" + pattern.lower())
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
),
all=True,
)
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
else queryset.order_by("username")
return queryset
# This ViewSet is the only one that is accessible from all authenticated users!
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = ContentType.objects.all()
serializer_class = ContentTypeSerializer

View File

@ -19,5 +19,5 @@ class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
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', ]
ordering_fields = ['timestamp', ] ordering_fields = ['timestamp', 'id', ]
ordering = ['-timestamp', ] ordering = ['-id', ]

View File

@ -0,0 +1,37 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Changelog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP Address')),
('instance_pk', models.CharField(max_length=255, verbose_name='identifier')),
('previous', models.TextField(null=True, verbose_name='previous data')),
('data', models.TextField(null=True, verbose_name='new data')),
('action', models.CharField(choices=[('create', 'create'), ('edit', 'edit'), ('delete', 'delete')], default='edit', max_length=16, verbose_name='action')),
('timestamp', models.DateTimeField(default=django.utils.timezone.now, verbose_name='timestamp')),
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType', verbose_name='model')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'changelog',
'verbose_name_plural': 'changelogs',
},
),
]

View File

@ -0,0 +1,17 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('logs', '0001_initial'),
]
operations = [
migrations.RunSQL(
"UPDATE logs_changelog SET previous = '' WHERE previous IS NULL;"
),
migrations.RunSQL(
"UPDATE logs_changelog SET data = '' WHERE data IS NULL;"
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.16 on 2020-09-06 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('logs', '0002_replace_null_by_blank'),
]
operations = [
migrations.AlterField(
model_name='changelog',
name='data',
field=models.TextField(blank=True, default='', verbose_name='new data'),
),
migrations.AlterField(
model_name='changelog',
name='previous',
field=models.TextField(blank=True, default='', verbose_name='previous data'),
),
]

View File

@ -44,12 +44,14 @@ class Changelog(models.Model):
) )
previous = models.TextField( previous = models.TextField(
null=True, blank=True,
default="",
verbose_name=_('previous data'), verbose_name=_('previous data'),
) )
data = models.TextField( data = models.TextField(
null=True, blank=True,
default="",
verbose_name=_('new data'), verbose_name=_('new data'),
) )
@ -80,3 +82,7 @@ class Changelog(models.Model):
class Meta: class Meta:
verbose_name = _("changelog") verbose_name = _("changelog")
verbose_name_plural = _("changelogs") verbose_name_plural = _("changelogs")
def __str__(self):
return _("Changelog of type \"{action}\" for model {model} at {timestamp}").format(
action=self.get_action_display(), model=str(self.model), timestamp=str(self.timestamp))

View File

@ -23,6 +23,9 @@ EXCLUDED = [
'cas_server.userattributes', 'cas_server.userattributes',
'contenttypes.contenttype', 'contenttypes.contenttype',
'logs.changelog', # Never remove this line 'logs.changelog', # Never remove this line
'mailer.dontsendentry',
'mailer.message',
'mailer.messagelog',
'migrations.migration', 'migrations.migration',
'note.note' # We only store the subclasses 'note.note' # We only store the subclasses
'note.transaction', 'note.transaction',
@ -47,10 +50,7 @@ def save_object(sender, instance, **kwargs):
in order to store each modification made in order to store each modification made
""" """
# noinspection PyProtectedMember # noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
if hasattr(instance, "_no_log"):
return return
# noinspection PyProtectedMember # noinspection PyProtectedMember
@ -78,19 +78,30 @@ def save_object(sender, instance, **kwargs):
if instance.last_login != previous.last_login: if instance.last_login != previous.last_login:
return return
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles changed_fields = '__all__'
if previous:
# On ne garde que les champs modifiés
changed_fields = []
for field in instance._meta.fields:
if field.name.endswith("_ptr"):
# A field ending with _ptr is a OneToOneRel with a subclass, e.g. NoteClub.note_ptr -> Note
continue
if getattr(instance, field.name) != getattr(previous, field.name):
changed_fields.append(field.name)
if len(changed_fields) == 0:
# Pas de log s'il n'y a pas de modification
return
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles avec uniquement les champs modifiés
class CustomSerializer(ModelSerializer): class CustomSerializer(ModelSerializer):
class Meta: class Meta:
model = instance.__class__ model = instance.__class__
fields = '__all__' fields = changed_fields
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else ""
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8") instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
if previous_json == instance_json:
# Pas de log s'il n'y a pas de modification
return
Changelog.objects.create(user=user, Changelog.objects.create(user=user,
ip=ip, ip=ip,
model=ContentType.objects.get_for_model(instance), model=ContentType.objects.get_for_model(instance),
@ -106,10 +117,7 @@ def delete_object(sender, instance, **kwargs):
Each time a model is deleted, an entry in the table `Changelog` is added in the database Each time a model is deleted, an entry in the table `Changelog` is added in the database
""" """
# noinspection PyProtectedMember # noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
if hasattr(instance, "_no_log"):
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
@ -141,6 +149,6 @@ def delete_object(sender, instance, **kwargs):
model=ContentType.objects.get_for_model(instance), model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk, instance_pk=instance.pk,
previous=instance_json, previous=instance_json,
data=None, data="",
action="delete" action="delete"
).save() ).save()

View File

@ -17,6 +17,7 @@ class ProfileInline(admin.StackedInline):
Inline user profile in user admin Inline user profile in user admin
""" """
model = Profile model = Profile
form = ProfileForm
can_delete = False can_delete = False
@ -25,15 +26,12 @@ class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline,) inlines = (ProfileInline,)
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff') list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
list_select_related = ('profile',) list_select_related = ('profile',)
form = ProfileForm
def get_inline_instances(self, request, obj=None): def get_inline_instances(self, request, obj=None):
""" """
When creating a new user don't show profile one the first step When creating a new user don't show profile one the first step
""" """
if not obj: return super().get_inline_instances(request, obj) if obj else []
return list()
return super().get_inline_instances(request, obj)
@admin.register(Club, site=admin_site) @admin.register(Club, site=admin_site)

View File

@ -1,31 +0,0 @@
[
{
"model": "member.club",
"pk": 1,
"fields": {
"name": "BDE",
"email": "tresorerie.bde@example.com",
"require_memberships": true,
"membership_fee_paid": 500,
"membership_fee_unpaid": 500,
"membership_duration": 396,
"membership_start": "2019-08-31",
"membership_end": "2020-09-30"
}
},
{
"model": "member.club",
"pk": 2,
"fields": {
"name": "Kfet",
"email": "tresorerie.bde@example.com",
"parent_club": 1,
"require_memberships": true,
"membership_fee_paid": 3500,
"membership_fee_unpaid": 3500,
"membership_duration": 396,
"membership_start": "2019-08-31",
"membership_end": "2020-09-30"
}
}
]

View File

@ -1,9 +1,16 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import io
from PIL import Image, ImageSequence
from django import forms from django import forms
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.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias from note.models import NoteSpecial, Alias
from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput
@ -14,7 +21,7 @@ from .models import Profile, Club, Membership
class CustomAuthenticationForm(AuthenticationForm): class CustomAuthenticationForm(AuthenticationForm):
permission_mask = forms.ModelChoiceField( permission_mask = forms.ModelChoiceField(
label="Masque de permissions", label=_("Permission mask"),
queryset=PermissionMask.objects.order_by("rank"), queryset=PermissionMask.objects.order_by("rank"),
empty_label=None, empty_label=None,
) )
@ -36,7 +43,22 @@ class ProfileForm(forms.ModelForm):
""" """
A form for the extras field provided by the :model:`member.Profile` model. A form for the extras field provided by the :model:`member.Profile` model.
""" """
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
def clean_promotion(self):
promotion = self.cleaned_data["promotion"]
if promotion > timezone.now().year:
self.add_error("promotion", _("You can't register to the note if you come from the future."))
return promotion
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
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})
@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):
@ -49,6 +71,67 @@ class ProfileForm(forms.ModelForm):
exclude = ('user', 'email_confirmed', 'registration_valid', ) exclude = ('user', 'email_confirmed', 'registration_valid', )
class ImageForm(forms.Form):
"""
Form used for the js interface for profile picture
"""
image = forms.ImageField(required=False,
label=_('select an image'),
help_text=_('Maximal size: 2MB'))
x = forms.FloatField(widget=forms.HiddenInput())
y = forms.FloatField(widget=forms.HiddenInput())
width = forms.FloatField(widget=forms.HiddenInput())
height = forms.FloatField(widget=forms.HiddenInput())
def clean(self):
"""
Load image and crop
In the future, when Pillow will support APNG we will be able to
simplify this code to save only PNG/APNG.
"""
cleaned_data = super().clean()
# Image size is limited by Django DATA_UPLOAD_MAX_MEMORY_SIZE
image = cleaned_data.get('image')
if image:
# Let Pillow detect and load image
# If it is an animation, then there will be multiple frames
try:
im = Image.open(image)
except OSError:
# Rare case in which Django consider the upload file as an image
# but Pil is unable to load it
raise forms.ValidationError(_('This image cannot be loaded.'))
# Crop each frame
x = cleaned_data.get('x', 0)
y = cleaned_data.get('y', 0)
w = cleaned_data.get('width', 200)
h = cleaned_data.get('height', 200)
frames = []
for frame in ImageSequence.Iterator(im):
frame = frame.crop((x, y, x + w, y + h))
frame = frame.resize(
(settings.PIC_WIDTH, settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS,
)
frames.append(frame)
# Save
om = frames.pop(0) # Get first frame
om.info = im.info # Copy metadata
image.file = io.BytesIO()
if len(frames) > 1:
# Save as GIF
om.save(image.file, "GIF", save_all=True, append_images=list(frames), loop=0)
else:
# Save as PNG
om.save(image.file, "PNG")
return cleaned_data
class ClubForm(forms.ModelForm): class ClubForm(forms.ModelForm):
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
@ -67,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/',
} }
@ -80,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(
@ -151,6 +235,7 @@ class MembershipRolesForm(forms.ModelForm):
roles = forms.ModelMultipleChoiceField( roles = forms.ModelMultipleChoiceField(
queryset=Role.objects.filter(weirole=None).all(), queryset=Role.objects.filter(weirole=None).all(),
label=_("Roles"), label=_("Roles"),
widget=CheckboxSelectMultiple(),
) )
class Meta: class Meta:

View File

@ -0,0 +1,76 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
import datetime
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import phonenumber_field.modelfields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Club',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('email', models.EmailField(max_length=254, verbose_name='email')),
('require_memberships', models.BooleanField(default=True, help_text="Uncheck if this club don't require memberships.", verbose_name='require memberships')),
('membership_fee_paid', models.PositiveIntegerField(default=0, verbose_name='membership fee (paid students)')),
('membership_fee_unpaid', models.PositiveIntegerField(default=0, verbose_name='membership fee (unpaid students)')),
('membership_duration', models.PositiveIntegerField(blank=True, help_text='The longest time (in days) a membership can last (NULL = infinite).', null=True, verbose_name='membership duration')),
('membership_start', models.DateField(blank=True, help_text='Date from which the members can renew their membership.', null=True, verbose_name='membership start')),
('membership_end', models.DateField(blank=True, help_text='Maximal date of a membership, after which members must renew it.', null=True, verbose_name='membership end')),
],
options={
'verbose_name': 'club',
'verbose_name_plural': 'clubs',
},
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=50, null=True, region=None, verbose_name='phone number')),
('section', models.CharField(blank=True, help_text='e.g. "1A0", "9A♥", "SAPHIRE"', max_length=255, null=True, verbose_name='section')),
('department', models.CharField(choices=[('A0', 'Informatics (A0)'), ('A1', 'Mathematics (A1)'), ('A2', 'Physics (A2)'), ("A'2", "Applied physics (A'2)"), ('A2', "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')),
('promotion', models.PositiveSmallIntegerField(default=2020, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion')),
('address', models.CharField(blank=True, max_length=255, null=True, verbose_name='address')),
('paid', models.BooleanField(default=False, help_text='Tells if the user receive a salary.', verbose_name='paid')),
('ml_events_registration', models.CharField(blank=True, choices=[(None, 'No'), ('fr', 'Yes (receive them in french)'), ('en', 'Yes (receive them in english)')], default=None, max_length=2, null=True, verbose_name='Register on the mailing list to stay informed of the events of the campus (1 mail/week)')),
('ml_sport_registration', models.BooleanField(default=False, verbose_name='Register on the mailing list to stay informed of the sport events of the campus (1 mail/week)')),
('ml_art_registration', models.BooleanField(default=False, verbose_name='Register on the mailing list to stay informed of the art events of the campus (1 mail/week)')),
('report_frequency', models.PositiveSmallIntegerField(default=0, verbose_name='report frequency (in days)')),
('last_report', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last report date')),
('email_confirmed', models.BooleanField(default=False, verbose_name='email confirmed')),
('registration_valid', models.BooleanField(default=False, verbose_name='registration valid')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'user profile',
'verbose_name_plural': 'user profile',
},
),
migrations.CreateModel(
name='Membership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_start', models.DateField(default=datetime.date.today, verbose_name='membership starts on')),
('date_end', models.DateField(null=True, verbose_name='membership ends on')),
('fee', models.PositiveIntegerField(verbose_name='fee')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='club')),
],
options={
'verbose_name': 'membership',
'verbose_name_plural': 'memberships',
},
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('permission', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('member', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='membership',
name='roles',
field=models.ManyToManyField(to='permission.Role', verbose_name='roles'),
),
migrations.AddField(
model_name='membership',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='club',
name='parent_club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='parent club'),
),
migrations.AddIndex(
model_name='profile',
index=models.Index(fields=['user'], name='member_prof_user_id_30c316_idx'),
),
migrations.AddIndex(
model_name='membership',
index=models.Index(fields=['user'], name='member_memb_user_id_945dbc_idx'),
),
]

View File

@ -0,0 +1,71 @@
from django.db import migrations
def create_bde_and_kfet(apps, schema_editor):
"""
The clubs BDE and Kfet are pre-injected.
"""
Club = apps.get_model("member", "club")
NoteClub = apps.get_model("note", "noteclub")
Alias = apps.get_model("note", "alias")
ContentType = apps.get_model('contenttypes', 'ContentType')
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
Club.objects.get_or_create(
id=1,
name="BDE",
email="tresorerie.bde@example.com",
require_memberships=True,
membership_fee_paid=500,
membership_fee_unpaid=500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
)
Club.objects.get_or_create(
id=2,
name="Kfet",
parent_club_id=1,
email="tresorerie.bde@example.com",
require_memberships=True,
membership_fee_paid=3500,
membership_fee_unpaid=3500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
)
NoteClub.objects.get_or_create(
id=5,
club_id=1,
polymorphic_ctype_id=polymorphic_ctype_id,
)
NoteClub.objects.get_or_create(
id=6,
club_id=2,
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):
dependencies = [
('member', '0002_auto_20200904_2341'),
('note', '0002_create_special_notes'),
]
operations = [
migrations.RunPython(create_bde_and_kfet),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('member', '0003_create_bde_and_kfet'),
]
operations = [
migrations.RunSQL(
"UPDATE member_profile SET address = '' WHERE address IS NULL;",
),
migrations.RunSQL(
"UPDATE member_profile SET ml_events_registration = '' WHERE ml_events_registration IS NULL;",
),
migrations.RunSQL(
"UPDATE member_profile SET section = '' WHERE section IS NULL;",
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 2.2.16 on 2020-09-06 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0004_replace_null_by_blank'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='address',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='address'),
),
migrations.AlterField(
model_name='profile',
name='ml_events_registration',
field=models.CharField(blank=True, choices=[('', 'No'), ('fr', 'Yes (receive them in french)'), ('en', 'Yes (receive them in english)')], default='', max_length=2, verbose_name='Register on the mailing list to stay informed of the events of the campus (1 mail/week)'),
),
migrations.AlterField(
model_name='profile',
name='section',
field=models.CharField(blank=True, default='', help_text='e.g. "1A0", "9A♥", "SAPHIRE"', max_length=255, verbose_name='section'),
),
]

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

@ -7,12 +7,16 @@ 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.template import loader from django.template import loader
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from permission.models import Role
from registration.tokens import email_validation_token from registration.tokens import email_validation_token
from note.models import MembershipTransaction from note.models import MembershipTransaction
@ -30,7 +34,7 @@ class Profile(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
phone_number = models.CharField( phone_number = PhoneNumberField(
verbose_name=_('phone number'), verbose_name=_('phone number'),
max_length=50, max_length=50,
blank=True, blank=True,
@ -42,7 +46,7 @@ class Profile(models.Model):
help_text=_('e.g. "1A0", "9A♥", "SAPHIRE"'), help_text=_('e.g. "1A0", "9A♥", "SAPHIRE"'),
max_length=255, max_length=255,
blank=True, blank=True,
null=True, default="",
) )
department = models.CharField( department = models.CharField(
@ -68,7 +72,7 @@ class Profile(models.Model):
] ]
) )
promotion = models.PositiveIntegerField( promotion = models.PositiveSmallIntegerField(
null=True, null=True,
default=datetime.date.today().year, default=datetime.date.today().year,
verbose_name=_("promotion"), verbose_name=_("promotion"),
@ -79,7 +83,7 @@ class Profile(models.Model):
verbose_name=_('address'), verbose_name=_('address'),
max_length=255, max_length=255,
blank=True, blank=True,
null=True, default="",
) )
paid = models.BooleanField( paid = models.BooleanField(
@ -88,6 +92,38 @@ class Profile(models.Model):
default=False, default=False,
) )
ml_events_registration = models.CharField(
blank=True,
default='',
max_length=2,
choices=[
('', _("No")),
('fr', _("Yes (receive them in french)")),
('en', _("Yes (receive them in english)")),
],
verbose_name=_("Register on the mailing list to stay informed of the events of the campus (1 mail/week)"),
)
ml_sport_registration = models.BooleanField(
default=False,
verbose_name=_("Register on the mailing list to stay informed of the sport events of the campus (1 mail/week)"),
)
ml_art_registration = models.BooleanField(
default=False,
verbose_name=_("Register on the mailing list to stay informed of the art events of the campus (1 mail/week)"),
)
report_frequency = models.PositiveSmallIntegerField(
verbose_name=_("report frequency (in days)"),
default=0,
)
last_report = models.DateTimeField(
verbose_name=_("last report date"),
default=timezone.now,
)
email_confirmed = models.BooleanField( email_confirmed = models.BooleanField(
verbose_name=_("email confirmed"), verbose_name=_("email confirmed"),
default=False, default=False,
@ -128,18 +164,30 @@ class Profile(models.Model):
indexes = [models.Index(fields=['user'])] indexes = [models.Index(fields=['user'])]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('user_detail', args=(self.pk,)) return reverse('member:user_detail', args=(self.user_id,))
def __str__(self):
return str(self.user)
def send_email_validation_link(self): def send_email_validation_link(self):
subject = _("Activate your Note Kfet account") subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
message = loader.render_to_string('registration/mails/email_validation_email.html', token = email_validation_token.make_token(self.user)
uid = urlsafe_base64_encode(force_bytes(self.user_id))
message = loader.render_to_string('registration/mails/email_validation_email.txt',
{ {
'user': self.user, 'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"), 'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': email_validation_token.make_token(self.user), 'token': token,
'uid': urlsafe_base64_encode(force_bytes(self.user.pk)), 'uid': uid,
}) })
self.user.email_user(subject, message) html = loader.render_to_string('registration/mails/email_validation_email.html',
{
'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': token,
'uid': uid,
})
self.user.email_user(subject, message, html_message=html)
class Club(models.Model): class Club(models.Model):
@ -195,17 +243,14 @@ class Club(models.Model):
blank=True, blank=True,
null=True, null=True,
verbose_name=_('membership start'), verbose_name=_('membership start'),
help_text=_('How long after January 1st the members can renew ' help_text=_('Date from which the members can renew their membership.'),
'their membership.'),
) )
membership_end = models.DateField( membership_end = models.DateField(
blank=True, blank=True,
null=True, null=True,
verbose_name=_('membership end'), verbose_name=_('membership end'),
help_text=_('How long the membership can last after January 1st ' help_text=_('Maximal date of a membership, after which members must renew it.'),
'of the next year after members can renew their '
'membership.'),
) )
def update_membership_dates(self): def update_membership_dates(self):
@ -226,6 +271,7 @@ class Club(models.Model):
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:
@ -294,24 +340,87 @@ class Membership(models.Model):
else: else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
def renew(self):
"""
If the current membership comes to expiration, create a new membership that starts immediately after this one.
"""
if not Membership.objects.filter(
user=self.user,
club=self.club,
date_start__gte=self.club.membership_start,
).exists():
# Membership is not renewed yet
new_membership = Membership(
user=self.user,
club=self.club,
date_start=max(self.date_end + datetime.timedelta(days=1), self.club.membership_start),
)
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
new_membership._force_renew_parent = True
if hasattr(self, '_soge') and self._soge:
new_membership._soge = True
if hasattr(self, '_force_save') and self._force_save:
new_membership._force_save = True
new_membership.save()
new_membership.roles.set(self.roles.all())
new_membership.save()
def renew_parent(self):
"""
Ensure that the parent membership is renewed, and renew/create it if needed.
"""
parent_membership = Membership.objects.filter(
user=self.user,
club=self.club.parent_club,
).order_by("-date_start")
if parent_membership.exists():
# Renew the previous membership of the parent club
parent_membership = parent_membership.first()
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.renew()
else:
# Create a new membership in the parent club
parent_membership = Membership(
user=self.user,
club=self.club.parent_club,
date_start=self.date_start,
)
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.save()
parent_membership.refresh_from_db()
if self.club.parent_club.name == "BDE":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
elif self.club.parent_club.name == "Kfet":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
else:
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
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.
""" """
if self.club.parent_club is not None: created = not self.pk
if not Membership.objects.filter(user=self.user, club=self.club.parent_club).exists(): if not created:
raise ValidationError(_('User is not a member of the parent club') + ' ' + self.club.parent_club.name)
if self.pk:
for role in self.roles.all(): for role in self.roles.all():
club = role.for_club club = role.for_club
if club is not None: if club is not None:
if club.pk != self.club_id: if club.pk != self.club_id:
raise ValidationError(_('The role {role} does not apply to the club {club}.') raise ValidationError(_('The role {role} does not apply to the club {club}.')
.format(role=role.name, club=club.name)) .format(role=role.name, club=club.name))
else:
created = not self.pk
if created:
if Membership.objects.filter( if Membership.objects.filter(
user=self.user, user=self.user,
club=self.club, club=self.club,
@ -320,15 +429,23 @@ class Membership(models.Model):
).exists(): ).exists():
raise ValidationError(_('User is already a member of the club')) raise ValidationError(_('User is already a member of the club'))
if self.user.profile.paid: if self.club.parent_club is not None:
self.fee = self.club.membership_fee_paid # Check that the user is already a member of the parent club if the membership is created
if not Membership.objects.filter(
user=self.user,
club=self.club.parent_club,
date_start__gte=self.club.parent_club.membership_start,
).exists():
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
self.renew_parent()
else: else:
self.fee = self.club.membership_fee_unpaid raise ValidationError(_('User is not a member of the parent club')
+ ' ' + self.club.parent_club.name)
if self.club.membership_duration is not None: self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration)
else: self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) \
self.date_end = self.date_start + datetime.timedelta(days=424242) if self.club.membership_duration is not None else self.date_start + datetime.timedelta(days=424242)
if self.club.membership_end is not None and self.date_end > self.club.membership_end: if self.club.membership_end is not None and self.date_end > self.club.membership_end:
self.date_end = self.club.membership_end self.date_end = self.club.membership_end
@ -353,13 +470,19 @@ class Membership(models.Model):
reason="Adhésion " + self.club.name, reason="Adhésion " + self.club.name,
) )
transaction._force_save = True transaction._force_save = True
print(hasattr(self, '_soge')) if hasattr(self, '_soge') and "treasury" in settings.INSTALLED_APPS\
if hasattr(self, '_soge') and "treasury" in settings.INSTALLED_APPS: and (self.club.name == "BDE" or self.club.name == "Kfet"
or ("wei" in settings.INSTALLED_APPS and hasattr(self.club, "weiclub") and self.club.weiclub)):
# If the soge pays, then the transaction is unvalidated in a first time, then submitted for control # If the soge pays, then the transaction is unvalidated in a first time, then submitted for control
# 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

@ -6,11 +6,10 @@ def save_user_profile(instance, created, raw, **_kwargs):
""" """
Hook to create and save a profile when an user is updated if it is not registered with the signup form Hook to create and save a profile when an user is updated if it is not registered with the signup form
""" """
if raw: if not raw and created and instance.is_active and not hasattr(instance, "_no_signal"):
# When provisionning data, do not try to autocreate
return
if created and instance.is_active:
from .models import Profile from .models import Profile
Profile.objects.get_or_create(user=instance) Profile.objects.get_or_create(user=instance)
if instance.is_superuser:
instance.profile.email_confirmed = True
instance.profile.registration_valid = True
instance.profile.save() instance.profile.save()

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,43 @@
/**
* On form submit, create a new alias
*/
function create_alias (e) {
// Do not submit HTML form
e.preventDefault()
// Get data and send to API
const formData = new FormData(e.target)
$.post('/api/note/alias/', {
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
name: formData.get('name'),
note: formData.get('note')
}).done(function () {
// Reload table
$('#alias_table').load(location.pathname + ' #alias_table')
addMsg('Alias ajouté', 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the alias
* @param Integer button_id Alias id to remove
*/
function delete_button (button_id) {
$.ajax({
url: '/api/note/alias/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
addMsg('Alias supprimé', 'success')
$('#alias_table').load(location.pathname + ' #alias_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
$(document).ready(function () {
// Attach event
document.getElementById('form_alias').addEventListener('submit', create_alias)
})

View File

@ -1,6 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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 date
import django_tables2 as tables import django_tables2 as tables
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -38,19 +39,22 @@ class UserTable(tables.Table):
""" """
List all users. List all users.
""" """
section = tables.Column(accessor='profile.section') alias = tables.Column()
balance = tables.Column(accessor='note.balance', verbose_name=_("Balance")) section = tables.Column(accessor='profile__section')
def render_balance(self, value): balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
return pretty_money(value)
def render_balance(self, record, value):
return pretty_money(value)\
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", record.note) else ""
class Meta: class Meta:
attrs = { attrs = {
'class': 'table table-condensed table-striped table-hover' 'class': 'table table-condensed table-striped table-hover'
} }
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('last_name', 'first_name', 'username', 'email') fields = ('last_name', 'first_name', 'username', 'alias', 'email')
model = User model = User
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
@ -92,8 +96,8 @@ class MembershipTable(tables.Table):
t = pretty_money(value) t = pretty_money(value)
# If it is required and if the user has the right, the renew button is displayed. # If it is required and if the user has the right, the renew button is displayed.
if record.club.membership_start is not None: if record.club.membership_start is not None \
if record.date_start < record.club.membership_start: # If the renew is available and record.date_start < record.club.membership_start:
if not Membership.objects.filter( if not Membership.objects.filter(
club=record.club, club=record.club,
user=record.user, user=record.user,
@ -103,15 +107,19 @@ class MembershipTable(tables.Table):
empty_membership = Membership( empty_membership = Membership(
club=record.club, club=record.club,
user=record.user, user=record.user,
date_start=datetime.now().date(), date_start=date.today(),
date_end=datetime.now().date(), date_end=date.today(),
fee=0, fee=0,
) )
if PermissionBackend.check_perm(get_current_authenticated_user(), if PermissionBackend.check_perm(get_current_authenticated_user(),
"member:add_membership", empty_membership): # If the user has right "member.add_membership", empty_membership): # If the user has right
t = format_html(t + ' <a class="btn btn-warning" href="{url}">{text}</a>', renew_url = reverse_lazy('member:club_renew_membership',
url=reverse_lazy('member:club_renew_membership', kwargs={"pk": record.pk})
kwargs={"pk": record.pk}), text=_("Renew")) t = format_html(
t + ' <a class="btn btn-sm btn-warning" title="{text}"'
' href="{renew_url}"><i class="fa fa-repeat"></i></a>',
renew_url=renew_url, text=_("Renew")
)
return t return t
def render_roles(self, record): def render_roles(self, record):
@ -125,7 +133,7 @@ class MembershipTable(tables.Table):
class Meta: class Meta:
attrs = { attrs = {
'class': 'table table-condensed table-striped table-hover', 'class': 'table table-condensed table-striped',
'style': 'table-layout: fixed;' 'style': 'table-layout: fixed;'
} }
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
@ -157,5 +165,5 @@ class ClubManagerTable(tables.Table):
'style': 'table-layout: fixed;' 'style': 'table-layout: fixed;'
} }
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('user', 'user.first_name', 'user.last_name', 'roles', ) fields = ('user', 'user__first_name', 'user__last_name', 'roles', )
model = Membership model = Membership

View File

@ -0,0 +1,89 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load crispy_forms_tags i18n pretty_money %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
{% if additional_fee_renewal %}
<div class="alert alert-warning">
{% if renewal %}
{% if club.name == "Kfet" %} {# Auto-renewal #}
{% 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 }}
will be charged to renew automatically the membership in this/these club·s.
{% endblocktrans %}
{% 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 %}
This club has parents {{ clubs }}. An additional fee of {{ pretty_fee }}
will be charged to adhere automatically to this/these club·s.
{% 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 %}
</div>
{% endif %}
<form method="post" action="">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
function autocompleted(user) {
$("#id_last_name").val(user.last_name);
$("#id_first_name").val(user.first_name);
$.getJSON("/api/members/profile/" + user.id + "/", function (profile) {
let fee = profile.paid ? "{{ club.membership_fee_paid }}" : "{{ club.membership_fee_unpaid }}";
$("#id_credit_amount").val((Number(fee) / 100).toFixed(2));
});
}
soge_field = $("#id_soge");
function fillFields() {
let checked = soge_field.is(':checked');
if (!checked) {
$("input").attr('disabled', false);
$("#id_user").attr('disabled', true);
$("select").attr('disabled', false);
return;
}
let credit_type = $("#id_credit_type");
credit_type.attr('disabled', true);
credit_type.val(4);
let credit_amount = $("#id_credit_amount");
credit_amount.attr('disabled', true);
credit_amount.val('{{ total_fee }}');
let bank = $("#id_bank");
bank.attr('disabled', true);
bank.val('Société générale');
}
soge_field.change(fillFields);
</script>
{% endblock %}

View File

@ -0,0 +1,184 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{# Use a fluid-width container #}
{% block containertype %}container-fluid{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-xl-4">
{% block profile_info %}
<div class="card bg-light" id="card-infos">
<h4 class="card-header text-center">
{% if user_object %}
{% trans "Account #" %}{{ user_object.pk }}
{% elif club %}
Club {{ club.name }}
{% endif %}
</h4>
<div class="text-center">
{% if user_object %}
<a href="{% url 'member:user_update_pic' user_object.pk %}">
<img src="{{ user_object.note.display_image.url }}" class="img-thumbnail mt-2">
</a>
{% elif club %}
<a href="{% url 'member:club_update_pic' club.pk %}">
<img src="{{ club.note.display_image.url }}" class="img-thumbnail mt-2">
</a>
{% endif %}
</div>
{% if note.inactivity_reason %}
<div class="alert alert-danger polymorphic-add-choice">
{{ note.get_inactivity_reason_display }}
</div>
{% endif %}
<div class="card-body" id="profile_infos">
{% if user_object %}
{% include "member/includes/profile_info.html" %}
{% elif club %}
{% include "member/includes/club_info.html" %}
{% endif %}
</div>
<div class="card-footer">
{% if user_object %}
<a class="btn btn-sm btn-secondary" href="{% url 'member:user_update_profile' user_object.pk %}">
<i class="fa fa-edit"></i> {% trans 'Update Profile' %}
</a>
{% url 'member:user_detail' user_object.pk as user_profile_url %}
{% if request.path_info != user_profile_url %}
<a class="btn btn-sm btn-primary" href="{{ user_profile_url }}">{% trans 'View Profile' %}</a>
{% endif %}
{% elif club and not club.weiclub %}
{% if can_add_members %}
<a class="btn btn-sm btn-success" href="{% url 'member:club_add_member' club_pk=club.pk %}"
data-turbolinks="false"> {% trans "Add member" %}</a>
{% endif %}
{% if ".change_"|has_perm:club %}
<a class="btn btn-sm btn-secondary" href="{% url 'member:club_update' pk=club.pk %}"
data-turbolinks="false">
<i class="fa fa-edit"></i> {% trans 'Update Profile' %}
</a>
{% endif %}
{% url 'member:club_detail' club.pk as club_detail_url %}
{% if request.path_info != club_detail_url %}
<a class="btn btn-sm btn-primary" href="{{ club_detail_url }}">{% trans 'View Profile' %}</a>
{% endif %}
{% endif %}
{% if can_lock_note %}
<button class="btn btn-sm btn-danger" data-toggle="modal" data-target="#lock-note-modal">
<i class="fa fa-ban"></i> {% trans 'Lock note' %}
</button>
{% elif can_unlock_note %}
<button class="btn btn-sm btn-success" data-toggle="modal" data-target="#unlock-note-modal">
<i class="fa fa-check-circle"></i> {% trans 'Unlock note' %}
</button>
{% endif %}
</div>
</div>
{% endblock %}
</div>
<div class="col-xl-8">
{% block profile_content %}{% endblock %}
</div>
{# Popup to confirm the action of locking the note. Managed by a button #}
<div class="modal fade" id="lock-note-modal" tabindex="-1" role="dialog" aria-labelledby="lockNote"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="lockNote">{% trans "Lock note" %}</h5>
<button type="button" class="close btn-modal" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% blocktrans trimmed %}
Are you sure you want to lock this note? This will prevent any transaction that would be performed,
until the note is unlocked.
{% endblocktrans %}
{% if can_force_lock %}
{% blocktrans trimmed %}
If you use the force mode, the user won't be able to unlock the note by itself.
{% endblocktrans %}
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-modal" data-dismiss="modal">{% trans "Close" %}</button>
{% if can_force_lock %}
<button type="button" class="btn btn-danger btn-modal" onclick="lock_note(true, 'forced')">{% trans "Force mode" %}</button>
{% endif %}
<button type="button" class="btn btn-warning btn-modal" onclick="lock_note(true, 'manual')">{% trans "Lock note" %}</button>
</div>
</div>
</div>
</div>
{# Popup to confirm the action of unlocking the note. Managed by a button #}
<div class="modal fade" id="unlock-note-modal" tabindex="-1" role="dialog" aria-labelledby="unlockNote"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="unlockNote">{% trans "Unlock note" %}</h5>
<button type="button" class="close btn-modal" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% blocktrans trimmed %}
Are you sure you want to unlock this note? Transactions will be re-enabled.
{% endblocktrans %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-modal" data-dismiss="modal">{% trans "Close" %}</button>
<button type="button" class="btn btn-success btn-modal" onclick="lock_note(false, null)">{% trans "Unlock note" %}</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
function refreshHistory() {
{% if user_object %}
$("#history_list").load("{% url 'member:user_detail' pk=user_object.pk %} #history_list");
$("#profile_infos").load("{% url 'member:user_detail' pk=user_object.pk %} #profile_infos");
{% else %}
$("#history_list").load("{% url 'member:club_detail' pk=club.pk %} #history_list");
$("#profile_infos").load("{% url 'member:club_detail' pk=club.pk %} #profile_infos");
{% endif %}
}
function lock_note(locked, mode) {
$("button.btn-modal").attr("disabled", "disabled");
$.ajax({
url: "/api/note/note/{{ note.pk }}/",
type: "PATCH",
dataType: "json",
headers: {
"X-CSRFTOKEN": CSRF_TOKEN
},
data: {
is_active: !locked,
inactivity_reason: mode,
resourcetype: "{% if user_object %}NoteUser{% else %}NoteClub{% endif %}"
}
}).done(function () {
$("#card-infos").load("#card-infos #card-infos", function () {
$(".modal").modal("hide");
$("button.btn-modal").removeAttr("disabled");
});
}).fail(function(xhr, textStatus, error) {
$(".modal").modal("hide");
$("button.btn-modal").removeAttr("disabled");
errMsg(xhr.responseJSON);
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% 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">
<h3 class="card-header text-center">
{% trans "Note aliases" %}
</h3>
<div class="card-body">
{% if can_create %}
<form class="input-group" method="POST" id="form_alias">
{% csrf_token %}
<input type="hidden" name="note" value="{{ object.note.pk }}">
<input type="text" name="name" class="form-control">
<div class="input-group-append">
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
</div>
</form>
{% endif %}
</div>
{% render_table aliases %}
</div>
{% endblock %}
{% block extrajavascript %}
<script src="{% static "member/js/alias.js" %}"></script>
{% endblock%}

View File

@ -0,0 +1,48 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n perms %}
{% block profile_content %}
{% if managers.data %}
<div class="card">
<div class="card-header position-relative" id="clubListHeading">
<a class="font-weight-bold">
<i class="fa fa-users"></i> {% trans "Club managers" %}
</a>
</div>
{% render_table managers %}
</div>
<hr>
{% endif %}
{% if member_list.data %}
<div class="card">
<div class="card-header position-relative" id="clubListHeading">
<a class="stretched-link font-weight-bold" href="{% url 'member:club_members' pk=club.pk %}">
<i class="fa fa-users"></i> {% trans "Club members" %}
</a>
</div>
{% render_table member_list %}
</div>
<hr>
{% endif %}
{% if history_list.data %}
<div class="card">
<div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold" {% if "note.view_note"|has_perm:club.note %}
href="{% url 'note:transactions' pk=club.note.pk %}" {% endif %}>
<i class="fa fa-euro"></i> {% trans "Transaction history" %}
</a>
</div>
<div id="history_list">
{% render_table history_list %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
require_memberships_obj = $("#id_require_memberships");
if (!require_memberships_obj.is(":checked")) {
$("#div_id_membership_fee_paid").toggle();
$("#div_id_membership_fee_unpaid").toggle();
$("#div_id_membership_duration").toggle();
$("#div_id_membership_start").toggle();
$("#div_id_membership_end").toggle();
}
require_memberships_obj.change(function () {
$("#div_id_membership_fee_paid").toggle();
$("#div_id_membership_fee_unpaid").toggle();
$("#div_id_membership_duration").toggle();
$("#div_id_membership_start").toggle();
$("#div_id_membership_end").toggle();
});
</script>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "base_search.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{% block content %}
{% if can_add_club %}
<a class="btn btn-block btn-success mb-3" href="{% url 'member:club_create' %}" data-turbolinks="false">
{% trans "Create club" %}
</a>
{% endif %}
{# Search panel #}
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note…">
<div class="form-check">
<label class="form-check-label" for="only_active">
<input type="checkbox" class="checkboxinput form-check-input" id="only_active"
{% if only_active %}checked{% endif %}>
{% trans "Display only active memberships" %}
</label>
</div>
<div id="div_id_roles">
<label for="roles" class="col-form-label">{% trans "Filter roles:" %}</label>
<select name="roles" class="selectmultiple form-control" id="roles" multiple="">
{% for role in applicable_roles %}
<option value="{{ role.id }}" selected>{{ role.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div id="memberships_table">
{% if table.data %}
{% render_table table %}
{% else %}
<div class="alert alert-warning">
{% trans "There is no membership found with this pattern." %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(document).ready(function () {
let searchbar_obj = $("#searchbar");
let only_active_obj = $("#only_active");
let roles_obj = $("#roles");
function reloadTable() {
let pattern = searchbar_obj.val();
let roles = [];
$("#roles option:selected").each(function () {
roles.push($(this).val());
});
let roles_str = roles.join(',');
$("#memberships_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") +
"&only_active=" + (only_active_obj.is(':checked') ? '1' : '0') +
"&roles=" + roles_str + " #memberships_table");
}
searchbar_obj.keyup(reloadTable);
only_active_obj.change(reloadTable);
roles_obj.change(reloadTable);
});
</script>
{% endblock %}

View File

@ -0,0 +1,57 @@
{% load i18n pretty_money perms %}
<dl class="row">
<dt class="col-xl-6">{% trans 'name'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.name }}</dd>
{% if club.parent_club %}
<dt class="col-xl-6">
<a href="{% url 'member:club_detail' club.parent_club.pk %}">{% trans 'Club Parent'|capfirst %}</a>
</dt>
<dd class="col-xl-6"> {{ club.parent_club.name }}</dd>
{% endif %}
{% if club.require_memberships %}
{% if club.membership_start %}
<dt class="col-xl-6">{% trans 'membership start'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_start }}</dd>
{% endif %}
{% if club.membership_end %}
<dt class="col-xl-6">{% trans 'membership end'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_end }}</dd>
{% endif %}
{% if club.membership_duration %}
<dt class="col-xl-6">{% trans 'membership duration'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_duration }} {% trans "days" %}</dd>
{% endif %}
{% if club.membership_fee_paid == club.membership_fee_unpaid %}
<dt class="col-xl-6">{% trans 'membership fee'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd>
{% else %}
<dt class="col-xl-6">{% trans 'membership fee (paid students)'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd>
<dt class="col-xl-6">{% trans 'membership fee (unpaid students)'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_fee_unpaid|pretty_money }}</dd>
{% endif %}
{% endif %}
{% if "note.view_note"|has_perm:club.note %}
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.note.balance | pretty_money }}</dd>
{% endif %}
<dt class="col-xl-6">{% trans 'aliases'|capfirst %}</dt>
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}">
<i class="fa fa-edit"></i>
{% trans 'Manage aliases' %} ({{ club.note.alias_set.all|length }})
</a>
</dd>
<dt class="col-xl-4">{% trans 'email'|capfirst %}</dt>
<dd class="col-xl-8"><a href="mailto:{{ club.email }}">{{ club.email }}</a></dd>
</dl>

View File

@ -0,0 +1,56 @@
{% load i18n pretty_money perms %}
<dl class="row">
<dt class="col-xl-6">{% trans 'name'|capfirst %}, {% trans 'first name' %}</dt>
<dd class="col-xl-6">{{ user_object.last_name }} {{ user_object.first_name }}</dd>
<dt class="col-xl-6">{% trans 'username'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.username }}</dd>
{% if user_object.pk == user.pk %}
<dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'password_change' %}">
<i class="fa fa-lock"></i>
{% trans 'Change password' %}
</a>
</dd>
{% endif %}
<dt class="col-xl-6">{% trans 'aliases'|capfirst %}</dt>
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:user_alias' user_object.pk %}">
<i class="fa fa-edit"></i>
{% trans 'Manage aliases' %} ({{ user_object.note.alias_set.all|length }})
</a>
</dd>
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.section }}</dd>
<dt class="col-xl-6">{% trans 'email'|capfirst %}</dt>
<dd class="col-xl-6"><a href="mailto:{{ user_object.email }}">{{ user_object.email }}</a></dd>
<dt class="col-xl-6">{% trans 'phone number'|capfirst %}</dt>
<dd class="col-xl-6"><a href="tel:{{ user_object.profile.phone_number }}">{{ user_object.profile.phone_number }}</a>
</dd>
<dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.address }}</dd>
{% if user_object.note and "note.view_note"|has_perm:user_object.note %}
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
<dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd>
{% endif %}
</dl>
{% if user_object.pk == user.pk %}
<div class="text-center">
<a class="small badge badge-secondary" href="{% url 'member:auth_token' %}">
<i class="fa fa-cogs"></i>{% trans 'API token' %}
</a>
</div>
{% endif %}

View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<div class="alert alert-info">
<h4>À quoi sert un jeton d'authentification ?</h4>
Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a>.<br />
Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token &lt;TOKEN&gt;</code>
pour pouvoir vous identifier.<br /><br />
Une documentation de l'API arrivera ultérieurement.
</div>
<div class="alert alert-info">
<strong>{%trans 'Token' %} :</strong>
{% if 'show' in request.GET %}
{{ token.key }} (<a href="?">cacher</a>)
{% else %}
<em>caché</em> (<a href="?show">montrer</a>)
{% endif %}
<br />
<strong>{%trans 'Created' %} :</strong> {{ token.created }}
</div>
<div class="alert alert-warning">
<strong>Attention :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
</div>
<a href="?regenerate">
<button class="btn btn-primary">{% trans 'Regenerate token' %}</button>
</a>
{% endblock %}

View File

@ -0,0 +1,113 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<div class="text-center">
<form method="post" enctype="multipart/form-data" id="formUpload">
{% csrf_token %}
{{ form |crispy }}
</form>
</div>
<!-- MODAL TO CROP THE IMAGE -->
<div class="modal fade" id="modalCrop">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
<img src="" id="modal-image" style="max-width: 100%;">
</div>
<div class="modal-footer">
<div class="btn-group pull-left" role="group">
<button type="button" class="btn btn-default" id="js-zoom-in">
<span class="glyphicon glyphicon-zoom-in"></span>
</button>
<button type="button" class="btn btn-default js-zoom-out">
<span class="glyphicon glyphicon-zoom-out"></span>
</button>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Nevermind" %}</button>
<button type="button" class="btn btn-primary js-crop-and-upload">{% trans "Crop and upload" %}</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extracss %}
<link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.css" rel="stylesheet">
{% endblock %}
{% block extrajavascript%}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-cropper@1.0.1/dist/jquery-cropper.min.js"></script>
<script>
$(function () {
/* SCRIPT TO OPEN THE MODAL WITH THE PREVIEW */
$("#id_image").change(function (e) {
if (this.files && this.files[0]) {
// Check the image size
if (this.files[0].size > 2*1024*1024) {
alert("Ce fichier est trop volumineux.")
} else {
// Read the selected image file
var reader = new FileReader();
reader.onload = function (e) {
$("#modal-image").attr("src", e.target.result);
$("#modalCrop").modal("show");
}
reader.readAsDataURL(this.files[0]);
}
}
});
/* SCRIPTS TO HANDLE THE CROPPER BOX */
var $image = $("#modal-image");
var cropBoxData;
var canvasData;
$("#modalCrop").on("shown.bs.modal", function () {
$image.cropper({
viewMode: 1,
aspectRatio: 1 / 1,
minCropBoxWidth: 200,
minCropBoxHeight: 200,
ready: function () {
$image.cropper("setCanvasData", canvasData);
$image.cropper("setCropBoxData", cropBoxData);
}
});
}).on("hidden.bs.modal", function () {
cropBoxData = $image.cropper("getCropBoxData");
canvasData = $image.cropper("getCanvasData");
$image.cropper("destroy");
});
$(".js-zoom-in").click(function () {
$image.cropper("zoom", 0.1);
});
$(".js-zoom-out").click(function () {
$image.cropper("zoom", -0.1);
});
/* SCRIPT TO COLLECT THE DATA AND POST TO THE SERVER */
$(".js-crop-and-upload").click(function () {
var cropData = $image.cropper("getData");
$("#id_x").val(cropData["x"]);
$("#id_y").val(cropData["y"]);
$("#id_height").val(cropData["height"]);
$("#id_width").val(cropData["width"]);
$("#formUpload").submit();
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% 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">
<h3 class="card-header text-center">
{% trans "Note aliases" %}
</h3>
<div class="card-body">
{% if can_create %}
<form class="input-group" method="POST" id="form_alias">
{% csrf_token %}
<input type="hidden" name="note" value="{{ object.note.pk }}">
<input type="text" name="name" class="form-control">
<div class="input-group-append">
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
</div>
</form>
{% endif %}
</div>
{% render_table aliases %}
</div>
{% endblock %}
{% block extrajavascript %}
<script src="{% static "member/js/alias.js" %}"></script>
{% endblock%}

View File

@ -0,0 +1,39 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n perms %}
{% block profile_content %}
{% if not object.profile.email_confirmed and "member.change_profile_email_confirmed"|has_perm:user_object.profile %}
<div class="alert alert-warning">
{% trans "This user doesn't have confirmed his/her e-mail address." %}
<a href="{% url "registration:email_validation_resend" pk=user_object.pk %}">
{% trans "Click here to resend a validation link." %}
</a>
</div>
{% endif %}
<div class="card bg-light mb-3">
<div class="card-header position-relative" id="clubListHeading">
<a class="font-weight-bold">
<i class="fa fa-users"></i> {% trans "View my memberships" %}
</a>
</div>
{% render_table club_list %}
</div>
<div class="card bg-light">
<div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold text-decoration-none"
{% if "note.view_note"|has_perm:user_object.note %}
href="{% url 'note:transactions' pk=user_object.note.pk %}" {% endif %}>
<i class="fa fa-euro"></i> {% trans "Transaction history" %}
</a>
</div>
<div id="history_list">
{% render_table history_list %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "member/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
{{ profile_form | crispy }}
<button class="btn btn-primary" type="submit">
{% trans "Save Changes" %}
</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "base_search.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{% block content %}
{% if "member.change_profile_registration_valid"|has_perm:user %}
<a class="btn btn-block btn-secondary mb-3" href="{% url 'registration:future_user_list' %}">
<i class="fa fa-user-plus"></i> {% trans "Registrations" %}
</a>
{% endif %}
{# Search panel #}
{{ block.super }}
{% endblock %}

View File

View File

@ -0,0 +1,22 @@
# Copyright (C) 2018-2020 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

@ -0,0 +1,56 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
"""
Test that login page still works
"""
class TemplateLoggedOutTests(TestCase):
def test_login_page(self):
response = self.client.get('/accounts/login/')
self.assertEqual(response.status_code, 200)
class TemplateLoggedInTests(TestCase):
fixtures = ('initial', )
def setUp(self):
self.user = User.objects.create_superuser(
username="admin",
password="adminadmin",
email="admin@example.com",
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
def test_login_page(self):
response = self.client.get(reverse("login"))
self.assertEqual(response.status_code, 200)
self.client.logout()
response = self.client.post('/accounts/login/', data=dict(
username="admin",
password="adminadmin",
permission_mask=3,
))
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
def test_logout(self):
response = self.client.get(reverse("logout"))
self.assertEqual(response.status_code, 200)
def test_admin_index(self):
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
def test_accounts_password_reset(self):
response = self.client.get('/accounts/password_reset/')
self.assertEqual(response.status_code, 200)

View File

@ -0,0 +1,405 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import hashlib
import os
from datetime import date, timedelta
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from member.models import Club, Membership, Profile
from note.models import Alias, NoteSpecial
from permission.models import Role
from treasury.models import SogeCredit
"""
Create some users and clubs and test that all pages are rendering properly
and that memberships are working.
"""
class TestMemberships(TestCase):
fixtures = ('initial', )
def setUp(self) -> None:
"""
Create a sample superuser, a club and a membership for all tests.
"""
self.user = User.objects.create_superuser(
username="toto",
email="toto@example.com",
password="toto",
)
self.user.profile.registration_valid = True
self.user.profile.email_confirmed = True
self.user.profile.save()
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.club = Club.objects.create(name="totoclub", parent_club=Club.objects.get(name="BDE"))
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_admin_pages(self):
"""
Check that Django Admin pages for the member app are loading successfully.
"""
response = self.client.get(reverse("admin:index") + "member/membership/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") + "member/club/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") + "auth/user/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") + "auth/user/" + str(self.user.pk) + "/change/")
self.assertEqual(response.status_code, 200)
def test_render_club_list(self):
"""
Render the list of all clubs, with a search.
"""
response = self.client.get(reverse("member:club_list"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:club_list") + "?search=toto")
self.assertEqual(response.status_code, 200)
def test_render_club_create(self):
"""
Try to create a new club.
"""
response = self.client.get(reverse("member:club_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:club_create"), data=dict(
name="Club toto",
email="clubtoto@example.com",
parent_club=self.club.pk,
require_memberships=False,
membership_fee_paid=0,
membership_fee_unpaid=0,
))
self.assertTrue(Club.objects.filter(name="Club toto").exists())
club = Club.objects.get(name="Club toto")
self.assertRedirects(response, club.get_absolute_url(), 302, 200)
def test_render_club_detail(self):
"""
Display the detail of a club.
"""
response = self.client.get(reverse("member:club_detail", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
def test_render_club_update(self):
"""
Try to update the information about a club.
"""
response = self.client.get(reverse("member:club_update", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:club_update", args=(self.club.pk, )), data=dict(
name="Toto club updated",
email="clubtoto@example.com",
require_memberships=True,
membership_fee_paid=0,
membership_fee_unpaid=0,
))
self.assertRedirects(response, self.club.get_absolute_url(), 302, 200)
self.assertTrue(Club.objects.exclude(name="Toto club updated"))
def test_render_club_update_picture(self):
"""
Try to update the picture of the note of a club.
"""
response = self.client.get(reverse("member:club_update_pic", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
old_pic = self.club.note.display_image
with open("apps/member/static/member/img/default_picture.png", "rb") as f:
image = SimpleUploadedFile("image.png", f.read(), "image/png")
response = self.client.post(reverse("member:club_update_pic", args=(self.club.pk,)), dict(
image=image,
x=0,
y=0,
width=200,
height=200,
))
self.assertRedirects(response, self.club.get_absolute_url(), 302, 200)
self.club.note.refresh_from_db()
self.assertTrue(os.path.exists(self.club.note.display_image.path))
os.remove(self.club.note.display_image.path)
self.club.note.display_image = old_pic
self.club.note.save()
def test_render_club_aliases(self):
"""
Display the list of the aliases of a club.
"""
# Alias creation and deletion is already tested in the note app
response = self.client.get(reverse("member:club_alias", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
def test_render_club_members(self):
"""
Display the list of the members of a club.
"""
response = self.client.get(reverse("member:club_members", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:club_members", args=(self.club.pk,)) + "?search=toto&roles="
+ ",".join([str(role.pk) for role in
Role.objects.filter(weirole__isnull=True).all()]))
self.assertEqual(response.status_code, 200)
def test_render_club_add_member(self):
"""
Try to add memberships and renew them.
"""
response = self.client.get(reverse("member:club_add_member", args=(Club.objects.get(name="BDE").pk,)))
self.assertEqual(response.status_code, 200)
user = User.objects.create(username="totototo")
user.profile.registration_valid = True
user.profile.email_confirmed = True
user.profile.save()
user.save()
# We create a club without any parent and one club with parent BDE (that is the club Kfet)
for bde_parent in False, True:
if bde_parent:
club = Club.objects.get(name="Kfet")
else:
club = Club.objects.create(
name="Second club " + ("with BDE" if bde_parent else "without BDE"),
parent_club=None,
email="newclub@example.com",
require_memberships=True,
membership_fee_paid=1000,
membership_fee_unpaid=500,
membership_start=date.today(),
membership_end=date.today() + timedelta(days=366),
membership_duration=366,
)
response = self.client.get(reverse("member:club_add_member", args=(club.pk,)))
self.assertEqual(response.status_code, 200)
# Create a new membership
response = self.client.post(reverse("member:club_add_member", args=(club.pk,)), data=dict(
user=user.pk,
date_start="{:%Y-%m-%d}".format(date.today()),
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Espèces").id,
credit_amount=4200,
last_name="TOTO",
first_name="Toto",
bank="Le matelas",
))
self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
self.assertTrue(Membership.objects.filter(user=user, club=club).exists())
# Membership is sent to the past to check renewals
membership = Membership.objects.get(user=user, club=club)
self.assertTrue(membership.valid)
membership.date_start = date(year=2000, month=1, day=1)
membership.date_end = date(year=2000, month=12, day=31)
membership.save()
self.assertFalse(membership.valid)
response = self.client.get(reverse("member:club_members", args=(club.pk,)) + "?only_active=0")
self.assertEqual(response.status_code, 200)
bde_membership = self.bde_membership
if bde_parent:
bde_membership = Membership.objects.get(club__name="BDE", user=user)
bde_membership.date_start = date(year=2000, month=1, day=1)
bde_membership.date_end = date(year=2000, month=12, day=31)
bde_membership.save()
response = self.client.get(reverse("member:club_renew_membership", args=(bde_membership.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:club_renew_membership", args=(membership.pk,)))
self.assertEqual(response.status_code, 200)
# Renew membership
response = self.client.post(reverse("member:club_renew_membership", args=(membership.pk,)), data=dict(
user=user.pk,
date_start="{:%Y-%m-%d}".format(date.today()),
soge=bde_parent,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=14242,
last_name="TOTO",
first_name="Toto",
bank="Bank",
))
self.assertRedirects(response, user.profile.get_absolute_url(), 302, 200)
response = self.client.get(club.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_auto_join_kfet_when_join_bde_with_soge(self):
"""
When we join the BDE club with a Soge registration, a Kfet membership is automatically created.
We check that it is the case.
"""
user = User.objects.create(username="new1A")
user.profile.registration_valid = True
user.profile.email_confirmed = True
user.profile.save()
user.save()
bde = Club.objects.get(name="BDE")
kfet = Club.objects.get(name="Kfet")
response = self.client.post(reverse("member:club_add_member", args=(bde.pk,)), data=dict(
user=user.pk,
date_start="{:%Y-%m-%d}".format(date.today()),
soge=True,
credit_type=NoteSpecial.objects.get(special_type="Virement bancaire").id,
credit_amount=(bde.membership_fee_paid + kfet.membership_fee_paid) / 100,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
))
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=kfet).exists())
self.assertTrue(SogeCredit.objects.filter(user=user).exists())
def test_change_roles(self):
"""
Check to change the roles of a membership.
"""
response = self.client.get(reverse("member:club_manage_roles", args=(self.membership.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:club_manage_roles", args=(self.membership.pk,)), data=dict(
roles=[role.id for role in Role.objects.filter(
Q(name="Membre de club") | Q(name="Trésorier·ère de club") | Q(name="Bureau de club")).all()],
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.membership.refresh_from_db()
self.assertEqual(self.membership.roles.count(), 3)
def test_render_user_list(self):
"""
Display the user search page.
"""
response = self.client.get(reverse("member:user_list"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:user_list") + "?search=toto")
self.assertEqual(response.status_code, 200)
def test_render_user_detail(self):
"""
Display the user detail page.
"""
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_render_user_update(self):
"""
Update some data about the user.
"""
response = self.client.get(reverse("member:user_update_profile", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:user_update_profile", args=(self.user.pk,)), data=dict(
first_name="Toto",
last_name="Toto",
username="toto changed",
email="updated@example.com",
phone_number="+33600000000",
section="",
department="A0",
promotion=timezone.now().year,
address="Earth",
paid=True,
ml_events_registration="en",
ml_sports_registration=True,
ml_art_registration=True,
report_frequency=7,
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.assertTrue(User.objects.filter(username="toto changed").exists())
self.assertTrue(Profile.objects.filter(address="Earth").exists())
self.assertTrue(Alias.objects.filter(normalized_name="totochanged").exists())
def test_render_user_update_picture(self):
"""
Update the note picture of the user.
"""
response = self.client.get(reverse("member:user_update_pic", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
old_pic = self.user.note.display_image
with open("apps/member/static/member/img/default_picture.png", "rb") as f:
image = SimpleUploadedFile("image.png", f.read(), "image/png")
response = self.client.post(reverse("member:user_update_pic", args=(self.user.pk,)), dict(
image=image,
x=0,
y=0,
width=200,
height=200,
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.user.note.refresh_from_db()
self.assertTrue(os.path.exists(self.user.note.display_image.path))
os.remove(self.user.note.display_image.path)
self.user.note.display_image = old_pic
self.user.note.save()
def test_render_user_aliases(self):
"""
Display the list of aliases of the user.
"""
# Alias creation and deletion is already tested in the note app
response = self.client.get(reverse("member:user_alias", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
def test_manage_auth_token(self):
"""
Display the page to see the API authentication token, see it and regenerate it.
:return:
"""
response = self.client.get(reverse("member:auth_token"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:auth_token") + "?view")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:auth_token") + "?regenerate")
self.assertRedirects(response, reverse("member:auth_token") + "?view", 302, 200)
def test_random_coverage(self):
# Useless, only for coverage
self.assertEqual(str(self.user), str(self.user.profile))
self.user.profile.promotion = None
self.assertEqual(self.user.profile.ens_year, 0)
self.membership.date_end = None
self.assertTrue(self.membership.valid)
def test_nk15_hasher(self):
"""
Test that NK15 passwords are successfully imported.
"""
salt = "42"
password = "strongpassword42"
hashed = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
self.user.password = "custom_nk15$1$" + salt + "|" + hashed
self.user.save()
self.assertTrue(self.user.check_password(password))

View File

@ -1,34 +1,33 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import io from datetime import timedelta, date
from datetime import datetime, timedelta
from PIL import Image
from django.conf import settings from django.conf import settings
from django.contrib.auth import logout 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.db.models import Q from django.db import transaction
from django.db.models import Q, F
from django.shortcuts import redirect from django.shortcuts import redirect
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.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView 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.forms import ImageForm
from note.models import Alias, NoteUser from note.models import Alias, NoteUser
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
from note_kfet.middlewares import _set_current_user_and_ip from note_kfet.middlewares import _set_current_user_and_ip
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 from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm, UserForm, MembershipRolesForm from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm,\
CustomAuthenticationForm, MembershipRolesForm
from .models import Club, Membership from .models import Club, Membership
from .tables import ClubTable, UserTable, MembershipTable, ClubManagerTable from .tables import ClubTable, UserTable, MembershipTable, ClubManagerTable
@ -39,6 +38,7 @@ 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) _set_current_user_and_ip(form.get_user(), self.request.session, None)
@ -49,6 +49,7 @@ class CustomLoginView(LoginView):
class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
Update the user information. Update the user information.
On this view both `:models:member.User` and `:models:member.Profile` are updated through forms
""" """
model = User model = User
form_class = UserForm form_class = UserForm
@ -69,48 +70,56 @@ 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.")
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)
if not self.object.profile.report_frequency:
del context['profile_form'].fields["last_report"]
return context return context
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
new_username = form.data['username'] """
# Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant Check if ProfileForm is correct
note = NoteUser.objects.filter( then check if username is not already taken by someone else or by the user,
alias__normalized_name=Alias.normalize(new_username)) then check if email has changed, and if so ask for new validation.
if note.exists() and note.get().user != self.object: """
form.add_error('username',
_("An alias with a similar name already exists."))
return super().form_invalid(form)
profile_form = ProfileForm( profile_form = ProfileForm(
data=self.request.POST, data=self.request.POST,
instance=self.object.profile, instance=self.object.profile,
) )
if form.is_valid() and profile_form.is_valid(): profile_form.full_clean()
if not profile_form.is_valid():
return super().form_invalid(form)
new_username = form.data['username'] new_username = form.data['username']
# Check if the new username is not already taken as an alias of someone else.
note = NoteUser.objects.filter(
alias__normalized_name=Alias.normalize(new_username))
if note.exists() and note.get().user != self.object:
form.add_error('username', _("An alias with a similar name already exists."))
return super().form_invalid(form)
# Check if the username is one of user's aliases.
alias = Alias.objects.filter(name=new_username) alias = Alias.objects.filter(name=new_username)
# Si le nouveau pseudo n'est pas un de nos alias,
# on supprime éventuellement un alias similaire pour le remplacer
if not alias.exists(): if not alias.exists():
similar = Alias.objects.filter( similar = Alias.objects.filter(
normalized_name=Alias.normalize(new_username)) normalized_name=Alias.normalize(new_username))
if similar.exists(): if similar.exists():
similar.delete() similar.delete()
olduser = User.objects.get(pk=form.instance.pk) olduser = User.objects.get(pk=form.instance.pk)
user = form.save(commit=False) user = form.save(commit=False)
profile = profile_form.save(commit=False)
profile.user = user
profile.save()
user.save()
if olduser.email != user.email: if olduser.email != user.email:
# If the user changed her/his email, then it is unvalidated and a confirmation link is sent. # If the user changed her/his email, then it is unvalidated and a confirmation link is sent.
user.profile.email_confirmed = False user.profile.email_confirmed = False
user.profile.save()
user.profile.send_email_validation_link() user.profile.send_email_validation_link()
profile = profile_form.save(commit=False)
profile.user = user
profile.save()
user.save()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
@ -120,7 +129,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
Affiche les informations sur un utilisateur, sa note, ses clubs... Display all information about a user.
""" """
model = User model = User
context_object_name = "user_object" context_object_name = "user_object"
@ -131,11 +140,15 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
We can't display information of a not registered user. We can't display information of a not registered user.
""" """
return super().get_queryset().filter(profile__registration_valid=True) return super().get_queryset(**kwargs).filter(profile__registration_valid=True)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""
Add history of transaction and list of membership of user.
"""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
user = context['user_object'] user = context['user_object']
context["note"] = user.note
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")\
@ -144,11 +157,40 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
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=datetime.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.user, 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
# Check permissions to see if the authenticated user can lock/unlock the note
with transaction.atomic():
modified_note = NoteUser.objects.get(pk=user.note.pk)
# Don't log these tests
modified_note._no_signal = True
modified_note.is_active = True
modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_noteuser_is_active",
modified_note)
old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk)
modified_note.inactivity_reason = 'forced'
modified_note._force_save = True
modified_note.save()
context["can_force_lock"] = user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_note_is_active", modified_note)
old_note._force_save = True
old_note._no_signal = True
old_note.save()
modified_note.refresh_from_db()
modified_note.is_active = True
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request.user, "note.change_note_is_active", modified_note)
return context return context
@ -165,25 +207,32 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
Filter the user list with the given pattern. Filter the user list with the given pattern.
""" """
qs = super().get_queryset().distinct().filter(profile__registration_valid=True) qs = super().get_queryset().annotate(alias=F("note__alias__name"))\
if "search" in self.request.GET: .annotate(normalized_alias=F("note__alias__normalized_name"))\
.filter(profile__registration_valid=True)
# Sqlite doesn't support order by in subqueries
qs = qs.order_by("username").distinct("username")\
if settings.DATABASES[qs.db]["ENGINE"] == 'django.db.backends.postgresql' else qs.distinct()
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
if not pattern:
return qs.none()
qs = qs.filter( qs = qs.filter(
Q(first_name__iregex=pattern) username__iregex="^" + pattern
| Q(last_name__iregex=pattern) ).union(
| Q(profile__section__iregex=pattern) qs.filter(
| Q(username__iregex="^" + pattern) (Q(alias__iregex="^" + pattern)
| Q(note__alias__name__iregex="^" + pattern) | Q(normalized_alias__iregex="^" + Alias.normalize(pattern))
| Q(note__alias__normalized_name__iregex=Alias.normalize("^" + pattern)) | Q(last_name__iregex="^" + pattern)
) | Q(first_name__iregex="^" + pattern)
| Q(email__istartswith=pattern))
& ~Q(username__iregex="^" + pattern)
), all=True)
else: else:
qs = qs.none() qs = qs.none()
return qs[:20] return qs
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
@ -198,7 +247,13 @@ 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.all()) context["aliases"] = AliasTable(note.alias_set.filter(PermissionBackend
.filter_queryset(self.request.user, Alias, "view")).all())
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
note=context["object"].note,
name="",
normalized_name="",
))
return context return context
@ -215,47 +270,35 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
return context return context
def get_success_url(self): def get_success_url(self):
"""Redirect to profile page after upload"""
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.id}) return reverse_lazy('member:user_detail', kwargs={'pk': self.object.id})
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
form = self.get_form() form = self.get_form()
self.object = self.get_object() self.object = self.get_object()
if form.is_valid(): return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
return self.form_valid(form)
else:
print('is_invalid')
print(form)
return self.form_invalid(form)
@transaction.atomic
def form_valid(self, form): def form_valid(self, form):
image_field = form.cleaned_data['image'] """Save image to note"""
x = form.cleaned_data['x'] image = form.cleaned_data['image']
y = form.cleaned_data['y']
w = form.cleaned_data['width'] # Rename as a PNG or GIF
h = form.cleaned_data['height'] extension = image.name.split(".")[-1]
# image crop and resize if extension == "gif":
image_file = io.BytesIO(image_field.read()) image.name = "{}_pic.gif".format(self.object.note.pk)
# ext = image_field.name.split('.')[-1].lower() else:
# TODO: support GIF format image.name = "{}_pic.png".format(self.object.note.pk)
image = Image.open(image_file)
image = image.crop((x, y, x + w, y + h)) # Save
image_clean = image.resize((settings.PIC_WIDTH, self.object.note.display_image = image
settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS)
image_file = io.BytesIO()
image_clean.save(image_file, "PNG")
image_field.file = image_file
# renaming
filename = "{}_pic.png".format(self.object.note.pk)
image_field.name = filename
self.object.note.display_image = image_field
self.object.note.save() self.object.note.save()
return super().form_valid(form) return super().form_valid(form)
class ProfilePictureUpdateView(PictureUpdateView): class ProfilePictureUpdateView(PictureUpdateView):
model = User model = User
template_name = 'member/profile_picture_update.html' template_name = 'member/picture_update.html'
context_object_name = 'user_object' context_object_name = 'user_object'
@ -270,8 +313,7 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists(): if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists():
Token.objects.get(user=self.request.user).delete() Token.objects.get(user=self.request.user).delete()
return redirect(reverse_lazy('member:auth_token') + "?show", return redirect(reverse_lazy('member:auth_token') + "?show")
permanent=True)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -286,7 +328,7 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
# ******************************* # # ******************************* #
class ClubCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): class ClubCreateView(ProtectQuerysetMixin, ProtectedCreateView):
""" """
Create Club Create Club
""" """
@ -295,8 +337,15 @@ class ClubCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
success_url = reverse_lazy('member:club_list') success_url = reverse_lazy('member:club_list')
extra_context = {"title": _("Create new club")} extra_context = {"title": _("Create new club")}
def form_valid(self, form): def get_sample_object(self):
return super().form_valid(form) return Club(
name="",
email="",
)
def get_success_url(self):
self.object.refresh_from_db()
return reverse_lazy("member:club_detail", kwargs={"pk": self.object.pk})
class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
@ -317,12 +366,20 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
qs = qs.filter( qs = qs.filter(
Q(name__iregex=pattern) Q(name__iregex=pattern)
| Q(note__alias__name__iregex="^" + pattern) | Q(note__alias__name__iregex=pattern)
| Q(note__alias__normalized_name__iregex=Alias.normalize("^" + pattern)) | Q(note__alias__normalized_name__iregex=Alias.normalize(pattern))
) )
return qs return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["can_add_club"] = PermissionBackend.check_perm(self.request.user, "member.add_club", Club(
name="",
email="club@example.com",
))
return context
class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
@ -333,26 +390,35 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
extra_context = {"title": _("Club detail")} extra_context = {"title": _("Club detail")}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""
Add list of managers (peoples with Permission/Roles in this club), history of transactions and members list
"""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = context["club"] club = context["club"]
if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club): if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club):
club.update_membership_dates() club.update_membership_dates()
# 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
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.user, 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))
context['history_list'] = history_table context['history_list'] = history_table
# member list
club_member = Membership.objects.filter( club_member = Membership.objects.filter(
club=club, club=club,
date_end__gte=datetime.today(), date_end__gte=date.today() - timedelta(days=15),
).filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view")) ).filter(PermissionBackend.filter_queryset(self.request.user, 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))
@ -362,8 +428,8 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
empty_membership = Membership( empty_membership = Membership(
club=club, club=club,
user=User.objects.first(), user=User.objects.first(),
date_start=datetime.now().date(), date_start=date.today(),
date_end=datetime.now().date(), date_end=date.today(),
fee=0, fee=0,
) )
context["can_add_members"] = PermissionBackend()\ context["can_add_members"] = PermissionBackend()\
@ -384,7 +450,13 @@ 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.all()) context["aliases"] = AliasTable(note.alias_set.filter(PermissionBackend
.filter_queryset(self.request.user, Alias, "view")).all())
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
note=context["object"].note,
name="",
normalized_name="",
))
return context return context
@ -416,14 +488,14 @@ class ClubPictureUpdateView(PictureUpdateView):
Update the profile picture of a club. Update the profile picture of a club.
""" """
model = Club model = Club
template_name = 'member/club_picture_update.html' template_name = 'member/picture_update.html'
context_object_name = 'club' context_object_name = 'club'
def get_success_url(self): def get_success_url(self):
return reverse_lazy('member:club_detail', kwargs={'pk': self.object.id}) return reverse_lazy('member:club_detail', kwargs={'pk': self.object.id})
class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
""" """
Add a membership to a club. Add a membership to a club.
""" """
@ -432,15 +504,42 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
template_name = 'member/add_members.html' template_name = 'member/add_members.html'
extra_context = {"title": _("Add new member to the club")} extra_context = {"title": _("Add new member to the club")}
def get_sample_object(self):
if "club_pk" in self.kwargs:
club = Club.objects.get(pk=self.kwargs["club_pk"])
else:
club = Membership.objects.get(pk=self.kwargs["pk"]).club
return Membership(
user=self.request.user,
club=club,
fee=0,
date_start=timezone.now(),
date_end=timezone.now() + timedelta(days=1),
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""
Membership can be created, or renewed
In case of creation the url is /club/<club_pk>/add_member
For a renewal it will be `club/renew_membership/<pk>`
"""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
form = context['form'] form = context['form']
if "club_pk" in self.kwargs: if "club_pk" in self.kwargs: # We create a new membership.
# 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.user, 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.
c = club
clubs_renewal = []
additional_fee_renewal = 0
while c.parent_club is not None:
c = c.parent_club
clubs_renewal.append(c)
additional_fee_renewal += c.membership_fee_paid
context["clubs_renewal"] = clubs_renewal
context["additional_fee_renewal"] = additional_fee_renewal
# If the concerned club is the BDE, then we add the option that Société générale pays the membership. # If the concerned club is the BDE, then we add the option that Société générale pays the membership.
if club.name != "BDE": if club.name != "BDE":
@ -452,27 +551,55 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
kfet = Club.objects.get(name="Kfet") kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid fee += kfet.membership_fee_paid
context["total_fee"] = "{:.02f}".format(fee / 100, ) context["total_fee"] = "{:.02f}".format(fee / 100, )
else: else: # This is a renewal. Fields can be pre-completed.
# This is a renewal. Fields can be pre-completed. context["renewal"] = True
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
user = old_membership.user user = old_membership.user
c = club
clubs_renewal = []
additional_fee_renewal = 0
while c.parent_club is not None:
c = c.parent_club
# check if a valid membership exists for the parent club
if c.membership_start and not Membership.objects.filter(
club=c,
user=user,
date_start__gte=c.membership_start,
).exists():
clubs_renewal.append(c)
additional_fee_renewal += c.membership_fee_paid if user.profile.paid else c.membership_fee_unpaid
context["clubs_renewal"] = clubs_renewal
context["additional_fee_renewal"] = additional_fee_renewal
form.fields['user'].initial = user form.fields['user'].initial = user
form.fields['user'].disabled = True form.fields['user'].disabled = True
form.fields['date_start'].initial = old_membership.date_end + timedelta(days=1) form.fields['date_start'].initial = old_membership.date_end + timedelta(days=1)
form.fields['credit_amount'].initial = club.membership_fee_paid if user.profile.paid \ form.fields['credit_amount'].initial = (club.membership_fee_paid if user.profile.paid
else club.membership_fee_unpaid else club.membership_fee_unpaid) + additional_fee_renewal
form.fields['last_name'].initial = user.last_name form.fields['last_name'].initial = user.last_name
form.fields['first_name'].initial = user.first_name form.fields['first_name'].initial = user.first_name
# If this is a renewal of a BDE membership, Société générale can pays, if it is not yet done # If this is a renewal of a BDE membership, Société générale can pays, if it has not been already done.
if club.name != "BDE" or user.profile.soge: if (club.name != "BDE" and club.name != "Kfet") or user.profile.soge:
del form.fields['soge'] del form.fields['soge']
else: else:
fee = 0 fee = 0
bde = Club.objects.get(name="BDE") bde = Club.objects.get(name="BDE")
if not Membership.objects.filter(
club=bde,
user=user,
date_start__gte=bde.membership_start,
).exists():
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
kfet = Club.objects.get(name="Kfet") kfet = Club.objects.get(name="Kfet")
if not Membership.objects.filter(
club=kfet,
user=user,
date_start__gte=bde.membership_start,
).exists():
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
context["total_fee"] = "{:.02f}".format(fee / 100, ) context["total_fee"] = "{:.02f}".format(fee / 100, )
@ -480,16 +607,89 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
return context return context
def perform_verifications(self, form, user, club, fee):
"""
Make some additional verifications to check that the membership can be created.
:return: True if the form is clean, False if there is an error.
"""
error = False
# Retrieve form data
credit_type = form.cleaned_data["credit_type"]
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")
if not credit_type:
credit_amount = 0
if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter(
club__name="Kfet",
user=user,
date_start__lte=date.today(),
date_end__gte=date.today(),
).exists():
# Users without a valid Kfet membership can't have a negative balance.
# TODO Send a notification to the user (with a mail?) to tell her/him to credit her/his note
form.add_error('user',
_("This user don't have enough money to join this club, and can't have a negative balance."))
error = True
if Membership.objects.filter(
user=form.instance.user,
club=club,
date_start__lte=form.instance.date_start,
date_end__gte=form.instance.date_start,
).exists():
form.add_error('user', _('User is already a member of the club'))
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,
date_end__lte=club.parent_club.membership_end,
).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:
form.add_error('user', _("The membership must start after {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
error = True
if club.membership_end and form.instance.date_start > club.membership_end:
form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.")
.format(form.instance.club.membership_end))
error = True
if credit_amount:
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."))
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
@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: 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.user, Club, "view")) \
.get(pk=self.kwargs["club_pk"]) .get(pk=self.kwargs["club_pk"])
user = form.instance.user user = form.instance.user
else: old_membership = None
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
user = old_membership.user user = old_membership.user
@ -498,11 +698,12 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
# Get form data # Get form data
credit_type = form.cleaned_data["credit_type"] credit_type = form.cleaned_data["credit_type"]
# but with this way users can customize their section as they want.
credit_amount = form.cleaned_data["credit_amount"] credit_amount = form.cleaned_data["credit_amount"]
last_name = form.cleaned_data["last_name"] last_name = form.cleaned_data["last_name"]
first_name = form.cleaned_data["first_name"] first_name = form.cleaned_data["first_name"]
bank = form.cleaned_data["bank"] bank = form.cleaned_data["bank"]
soge = form.cleaned_data["soge"] and not user.profile.soge and club.name == "BDE" soge = form.cleaned_data["soge"] and not user.profile.soge and (club.name == "BDE" or club.name == "Kfet")
# If Société générale pays, then we store that information but the payment must be controlled by treasurers # If Société générale pays, then we store that information but the payment must be controlled by treasurers
# later. The membership transaction will be invalidated. # later. The membership transaction will be invalidated.
@ -513,67 +714,32 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
if credit_type is None: if credit_type is None:
credit_amount = 0 credit_amount = 0
if user.profile.paid: fee = 0
fee = club.membership_fee_paid c = club
else: # collect the fees required to be paid
fee = club.membership_fee_unpaid while c is not None and c.membership_start:
if user.note.balance + credit_amount < fee and not Membership.objects.filter( if not Membership.objects.filter(
club__name="Kfet", club=c,
user=user, user=user,
date_start__lte=datetime.now().date(), date_start__gte=c.membership_start,
date_end__gte=datetime.now().date(),
).exists(): ).exists():
# Users without a valid Kfet membership can't have a negative balance. fee += c.membership_fee_paid if user.profile.paid else c.membership_fee_unpaid
# Club 2 = Kfet (hard-code :'( ) c = c.parent_club
# TODO Send a notification to the user (with a mail?) to tell her/him to credit her/his note
form.add_error('user',
_("This user don't have enough money to join this club, and can't have a negative balance."))
return super().form_invalid(form)
if club.parent_club is not None: # Make some verifications about the form, and if there is an error, then assume that the form is invalid
if not Membership.objects.filter(user=form.instance.user, club=club.parent_club).exists(): if not self.perform_verifications(form, user, club, fee):
form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name) return self.form_invalid(form)
return super().form_invalid(form)
if Membership.objects.filter(
user=form.instance.user,
club=club,
date_start__lte=form.instance.date_start,
date_end__gte=form.instance.date_start,
).exists():
form.add_error('user', _('User is already a member of the club'))
return super().form_invalid(form)
if club.membership_start and form.instance.date_start < club.membership_start:
form.add_error('user', _("The membership must start after {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
return super().form_invalid(form)
if club.membership_end and form.instance.date_start > club.membership_end:
form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
return super().form_invalid(form)
# Now, all is fine, the membership can be created. # Now, all is fine, the membership can be created.
if club.name == "BDE": if club.name == "BDE" or club.name == "Kfet":
# When we renew the BDE membership, we update the profile section. # When we renew the BDE membership, we update the profile section
# We could automate that and remove the section field from the Profile model, # that should happens at least once a year.
# but with this way users can customize their section as they want.
user.profile.section = user.profile.section_generated user.profile.section = user.profile.section_generated
user.profile.save() user.profile.save()
# Credit note before the membership is created. # Credit note before the membership is created.
if credit_amount > 0: if credit_amount > 0:
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."))
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)
transaction = SpecialTransaction( transaction = SpecialTransaction(
source=credit_type, source=credit_type,
destination=user.note, destination=user.note,
@ -588,32 +754,41 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
transaction._force_save = True transaction._force_save = True
transaction.save() transaction.save()
# Parent club memberships are automatically renewed / created.
# For example, a Kfet membership creates a BDE membership if it does not exist.
form.instance._force_renew_parent = True
ret = super().form_valid(form) ret = super().form_valid(form)
member_role = Role.objects.filter(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 == "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()
# If Société générale pays, then we assume that this is the BDE membership, and we auto-renew the # If Société générale pays, then we assume that this is the BDE membership, and we auto-renew the
# Kfet membership. # Kfet membership.
if soge: if soge and club.name == "BDE":
kfet = Club.objects.get(name="Kfet") kfet = Club.objects.get(name="Kfet")
kfet_fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
# Get current membership, to get the end date # Get current membership, to get the end date
old_membership = Membership.objects.filter( old_membership = Membership.objects.filter(
club__name="Kfet", club=kfet,
user=user, user=user,
date_start__lte=datetime.today(), ).order_by("-date_start")
date_end__gte=datetime.today(),
)
if not old_membership.filter(date_start__gte=kfet.membership_start).exists():
# If the membership is not already renewed
membership = Membership( membership = Membership(
club=kfet, club=kfet,
user=user, user=user,
fee=kfet_fee, fee=fee,
date_start=old_membership.get().date_end + timedelta(days=1) date_start=max(old_membership.first().date_end + timedelta(days=1), kfet.membership_start)
if old_membership.exists() else form.instance.date_start, if old_membership.exists() else form.instance.date_start,
) )
membership._force_save = True membership._force_save = True
@ -622,14 +797,13 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
membership.refresh_from_db() membership.refresh_from_db()
if old_membership.exists(): if old_membership.exists():
membership.roles.set(old_membership.get().roles.all()) membership.roles.set(old_membership.get().roles.all())
else: membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
membership.save() membership.save()
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):
@ -683,9 +857,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
qs = qs.filter(date_start__lte=timezone.now().today(), date_end__gte=timezone.now().today()) qs = qs.filter(date_start__lte=timezone.now().today(), date_end__gte=timezone.now().today())
if "roles" in self.request.GET: if "roles" in self.request.GET:
if not self.request.GET["roles"]: roles_str = self.request.GET["roles"].replace(' ', '').split(',') if self.request.GET["roles"] else ['0']
return qs.none()
roles_str = self.request.GET["roles"].replace(' ', '').split(',')
roles_int = map(int, roles_str) roles_int = map(int, roles_str)
qs = qs.filter(roles__in=roles_int) qs = qs.filter(roles__in=roles_int)

View File

@ -119,10 +119,6 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
list_display = ('created_at', 'poly_source', 'poly_destination', list_display = ('created_at', 'poly_source', 'poly_destination',
'quantity', 'amount', 'valid') 'quantity', 'amount', 'valid')
list_filter = ('valid',) list_filter = ('valid',)
readonly_fields = (
'source',
'destination',
)
def poly_source(self, obj): def poly_source(self, obj):
""" """
@ -145,10 +141,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
Only valid can be edited after creation Only valid can be edited after creation
Else the amount of money would not be transferred Else the amount of money would not be transferred
""" """
if obj: # user is editing an existing object return 'created_at', 'source', 'destination', 'quantity', 'amount' if obj else ()
return 'created_at', 'source', 'destination', 'quantity', \
'amount'
return []
@admin.register(MembershipTransaction, site=admin_site) @admin.register(MembershipTransaction, site=admin_site)
@ -157,6 +150,13 @@ class MembershipTransactionAdmin(PolymorphicChildModelAdmin):
Admin customisation for MembershipTransaction Admin customisation for MembershipTransaction
""" """
def get_readonly_fields(self, request, obj=None):
"""
Only valid can be edited after creation
Else the amount of money would not be transferred
"""
return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else ()
@admin.register(RecurrentTransaction, site=admin_site) @admin.register(RecurrentTransaction, site=admin_site)
class RecurrentTransactionAdmin(PolymorphicChildModelAdmin): class RecurrentTransactionAdmin(PolymorphicChildModelAdmin):
@ -164,6 +164,13 @@ class RecurrentTransactionAdmin(PolymorphicChildModelAdmin):
Admin customisation for RecurrentTransaction Admin customisation for RecurrentTransaction
""" """
def get_readonly_fields(self, request, obj=None):
"""
Only valid can be edited after creation
Else the amount of money would not be transferred
"""
return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else ()
@admin.register(SpecialTransaction, site=admin_site) @admin.register(SpecialTransaction, site=admin_site)
class SpecialTransactionAdmin(PolymorphicChildModelAdmin): class SpecialTransactionAdmin(PolymorphicChildModelAdmin):
@ -171,6 +178,13 @@ class SpecialTransactionAdmin(PolymorphicChildModelAdmin):
Admin customisation for SpecialTransaction Admin customisation for SpecialTransaction
""" """
def get_readonly_fields(self, request, obj=None):
"""
Only valid can be edited after creation
Else the amount of money would not be transferred
"""
return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else ()
@admin.register(TransactionTemplate, site=admin_site) @admin.register(TransactionTemplate, site=admin_site)
class TransactionTemplateAdmin(admin.ModelAdmin): class TransactionTemplateAdmin(admin.ModelAdmin):

View File

@ -1,10 +1,16 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
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.models import Membership
from note_kfet.middlewares import get_current_authenticated_user from note_kfet.middlewares import get_current_authenticated_user
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from rest_framework.utils import model_meta
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \ from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
@ -20,7 +26,7 @@ class NoteSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Note model = Note
fields = '__all__' fields = '__all__'
read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected read_only_fields = ('balance', 'last_negative', 'created_at', ) # Note balances are read-only protected
class NoteClubSerializer(serializers.ModelSerializer): class NoteClubSerializer(serializers.ModelSerializer):
@ -33,7 +39,7 @@ class NoteClubSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = NoteClub model = NoteClub
fields = '__all__' fields = '__all__'
read_only_fields = ('note', 'club', ) read_only_fields = ('note', 'club', 'balance', 'last_negative', 'created_at', )
def get_name(self, obj): def get_name(self, obj):
return str(obj) return str(obj)
@ -49,7 +55,7 @@ class NoteSpecialSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = NoteSpecial model = NoteSpecial
fields = '__all__' fields = '__all__'
read_only_fields = ('note', ) read_only_fields = ('note', 'balance', 'last_negative', 'created_at', )
def get_name(self, obj): def get_name(self, obj):
return str(obj) return str(obj)
@ -65,7 +71,7 @@ class NoteUserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = NoteUser model = NoteUser
fields = '__all__' fields = '__all__'
read_only_fields = ('note', 'user', ) read_only_fields = ('note', 'user', 'balance', 'last_negative', 'created_at', )
def get_name(self, obj): def get_name(self, obj):
return str(obj) return str(obj)
@ -108,6 +114,8 @@ class ConsumerSerializer(serializers.ModelSerializer):
email_confirmed = serializers.SerializerMethodField() email_confirmed = serializers.SerializerMethodField()
membership = serializers.SerializerMethodField()
class Meta: class Meta:
model = Alias model = Alias
fields = '__all__' fields = '__all__'
@ -117,15 +125,31 @@ class ConsumerSerializer(serializers.ModelSerializer):
Display information about the associated note Display information about the associated note
""" """
# 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
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note): 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)\
return dict(id=obj.note.id, name=str(obj.note)) else dict(
id=obj.note.id,
name=str(obj.note),
is_active=obj.note.is_active,
display_image=obj.note.display_image.url,
)
def get_email_confirmed(self, obj): def get_email_confirmed(self, obj):
if isinstance(obj.note, NoteUser): if isinstance(obj.note, NoteUser):
return obj.note.user.profile.email_confirmed return obj.note.user.profile.email_confirmed
return True return True
def get_membership(self, obj):
if isinstance(obj.note, NoteUser):
memberships = Membership.objects.filter(
PermissionBackend.filter_queryset(get_current_authenticated_user(), Membership, "view")).filter(
user=obj.note.user,
club=2, # Kfet
).order_by("-date_start")
if memberships.exists():
return MembershipSerializer().to_representation(memberships.first())
return None
class TemplateCategorySerializer(serializers.ModelSerializer): class TemplateCategorySerializer(serializers.ModelSerializer):
""" """
@ -154,13 +178,24 @@ class TransactionSerializer(serializers.ModelSerializer):
REST API Serializer for Transactions. REST API Serializer for Transactions.
The djangorestframework plugin will analyse the model `Transaction` and parse all fields in the API. The djangorestframework plugin will analyse the model `Transaction` and parse all fields in the API.
""" """
def validate_source(self, value):
if not value.is_active:
raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active."))
return value
def validate_destination(self, value):
if not value.is_active:
raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active."))
return value
class Meta: class Meta:
model = Transaction model = Transaction
fields = '__all__' fields = '__all__'
class RecurrentTransactionSerializer(serializers.ModelSerializer): class RecurrentTransactionSerializer(TransactionSerializer):
""" """
REST API Serializer for Transactions. REST API Serializer for Transactions.
The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API. The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API.
@ -171,7 +206,7 @@ class RecurrentTransactionSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
class MembershipTransactionSerializer(serializers.ModelSerializer): class MembershipTransactionSerializer(TransactionSerializer):
""" """
REST API Serializer for Membership transactions. REST API Serializer for Membership transactions.
The djangorestframework plugin will analyse the model `MembershipTransaction` and parse all fields in the API. The djangorestframework plugin will analyse the model `MembershipTransaction` and parse all fields in the API.
@ -182,7 +217,7 @@ class MembershipTransactionSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
class SpecialTransactionSerializer(serializers.ModelSerializer): class SpecialTransactionSerializer(TransactionSerializer):
""" """
REST API Serializer for Special transactions. REST API Serializer for Special transactions.
The djangorestframework plugin will analyse the model `SpecialTransaction` and parse all fields in the API. The djangorestframework plugin will analyse the model `SpecialTransaction` and parse all fields in the API.
@ -202,12 +237,28 @@ class TransactionPolymorphicSerializer(PolymorphicSerializer):
SpecialTransaction: SpecialTransactionSerializer, SpecialTransaction: SpecialTransactionSerializer,
} }
try: if "activity" in settings.INSTALLED_APPS:
from activity.models import GuestTransaction from activity.models import GuestTransaction
from activity.api.serializers import GuestTransactionSerializer from activity.api.serializers import GuestTransactionSerializer
model_serializer_mapping[GuestTransaction] = GuestTransactionSerializer model_serializer_mapping[GuestTransaction] = GuestTransactionSerializer
except ImportError: # Activity app is not loaded
pass def validate(self, attrs):
resource_type = attrs.pop(self.resource_type_field_name)
serializer = self._get_serializer_from_resource_type(resource_type)
if self.instance:
instance = self.instance
info = model_meta.get_field_info(instance)
for attr, value in attrs.items():
if attr in info.relations and info.relations[attr].to_many:
field = getattr(instance, attr)
field.set(value)
else:
setattr(instance, attr, value)
instance.validate()
else:
serializer.Meta.model(**attrs).validate()
attrs[self.resource_type_field_name] = resource_type
return super().validate(attrs)
class Meta: class Meta:
model = Transaction model = Transaction

View File

@ -1,6 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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.db.models import Q from django.db.models import Q
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -9,6 +10,8 @@ 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 .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\ from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
@ -16,7 +19,7 @@ from ..models.notes import Note, Alias
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet): 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,
@ -34,15 +37,16 @@ class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet):
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() queryset = super().get_queryset().distinct()
alias = self.request.query_params.get("alias", ".*") alias = self.request.query_params.get("alias", ".*")
queryset = queryset.filter( queryset = queryset.filter(
Q(alias__name__regex="^" + alias) Q(alias__name__iregex="^" + alias)
| Q(alias__normalized_name__regex="^" + Alias.normalize(alias)) | Q(alias__normalized_name__iregex="^" + Alias.normalize(alias))
| Q(alias__normalized_name__regex="^" + alias.lower())) | Q(alias__normalized_name__iregex="^" + alias.lower())
)
return queryset.distinct() return queryset.order_by("id")
class AliasViewSet(ReadProtectedModelViewSet): class AliasViewSet(ReadProtectedModelViewSet):
@ -53,8 +57,9 @@ class AliasViewSet(ReadProtectedModelViewSet):
""" """
queryset = Alias.objects.all() queryset = Alias.objects.all()
serializer_class = AliasSerializer serializer_class = AliasSerializer
filter_backends = [SearchFilter, OrderingFilter] filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['note']
ordering_fields = ['name', 'normalized_name'] ordering_fields = ['name', 'normalized_name']
def get_serializer_class(self): def get_serializer_class(self):
@ -69,7 +74,6 @@ class AliasViewSet(ReadProtectedModelViewSet):
try: try:
self.perform_destroy(instance) self.perform_destroy(instance)
except ValidationError as e: except ValidationError as e:
print(e)
return Response({e.code: e.message}, status.HTTP_400_BAD_REQUEST) return Response({e.code: e.message}, status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -79,22 +83,34 @@ class AliasViewSet(ReadProtectedModelViewSet):
:return: The filtered set of requested aliases :return: The filtered set of requested aliases
""" """
queryset = super().get_queryset() queryset = super().get_queryset().distinct()
alias = self.request.query_params.get("alias", ".*") alias = self.request.query_params.get("alias", None)
if alias:
queryset = queryset.filter( queryset = queryset.filter(
Q(name__regex="^" + alias) name__iregex="^" + alias
| Q(normalized_name__regex="^" + Alias.normalize(alias)) ).union(
| Q(normalized_name__regex="^" + alias.lower())) queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True).union(
queryset.filter(
Q(normalized_name__iregex="^" + alias.lower())
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True)
return queryset return queryset.order_by("name")
class ConsumerViewSet(ReadOnlyProtectedModelViewSet): class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
queryset = Alias.objects.all() queryset = Alias.objects.all()
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', ]
filterset_fields = ['note']
ordering_fields = ['name', 'normalized_name'] ordering_fields = ['name', 'normalized_name']
def get_queryset(self): def get_queryset(self):
@ -103,16 +119,36 @@ 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
queryset = queryset.order_by("name") \
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)
queryset = queryset.prefetch_related('note')
if alias:
# We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias.
queryset = queryset.filter( queryset = queryset.filter(
Q(name__regex="^" + alias) name__iregex="^" + alias
| Q(normalized_name__regex="^" + Alias.normalize(alias)) ).union(
| Q(normalized_name__regex="^" + alias.lower()))\ queryset.filter(
.order_by('name').prefetch_related('note') Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True).union(
queryset.filter(
Q(normalized_name__iregex="^" + alias.lower())
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True)
return queryset queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
else queryset.order_by("name")
return queryset.distinct()
class TemplateCategoryViewSet(ReadProtectedModelViewSet): class TemplateCategoryViewSet(ReadProtectedModelViewSet):
@ -121,7 +157,7 @@ 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.all() queryset = TemplateCategory.objects.order_by("name").all()
serializer_class = TemplateCategorySerializer serializer_class = TemplateCategorySerializer
filter_backends = [SearchFilter] filter_backends = [SearchFilter]
search_fields = ['$name', ] search_fields = ['$name', ]
@ -133,7 +169,7 @@ 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.all() queryset = TransactionTemplate.objects.order_by("name").all()
serializer_class = TransactionTemplateSerializer serializer_class = TransactionTemplateSerializer
filter_backends = [SearchFilter, DjangoFilterBackend] filter_backends = [SearchFilter, DjangoFilterBackend]
filterset_fields = ['name', 'amount', 'display', 'category', ] filterset_fields = ['name', 'amount', 'display', 'category', ]
@ -146,7 +182,16 @@ 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.all() queryset = Transaction.objects.order_by("-created_at").all()
serializer_class = TransactionPolymorphicSerializer serializer_class = TransactionPolymorphicSerializer
filter_backends = [SearchFilter] filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ["source", "source_alias", "destination", "destination_alias", "quantity",
"polymorphic_ctype", "amount", "created_at", ]
search_fields = ['$reason', ] search_fields = ['$reason', ]
ordering_fields = ['created_at', 'amount']
def get_queryset(self):
user = self.request.user
get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\
.order_by("created_at", "id")

View File

@ -3,7 +3,7 @@
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 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,
@ -25,3 +34,8 @@ class NoteConfig(AppConfig):
signals.save_club_note, signals.save_club_note,
sender='member.Club', sender='member.Club',
) )
pre_delete.connect(
signals.delete_transaction,
sender='note.transaction',
)

View File

@ -1,188 +0,0 @@
[
{
"model": "note.note",
"pk": 1,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:02:48.778Z"
}
},
{
"model": "note.note",
"pk": 2,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:39.546Z"
}
},
{
"model": "note.note",
"pk": 3,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:43.049Z"
}
},
{
"model": "note.note",
"pk": 4,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:50.996Z"
}
},
{
"model": "note.note",
"pk": 5,
"fields": {
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "pic/default.png",
"created_at": "2020-02-20T20:09:38.615Z"
}
},
{
"model": "note.note",
"pk": 6,
"fields": {
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "pic/default.png",
"created_at": "2020-02-20T20:16:14.753Z"
}
},
{
"model": "note.noteclub",
"pk": 5,
"fields": {
"club": 1
}
},
{
"model": "note.noteclub",
"pk": 6,
"fields": {
"club": 2
}
},
{
"model": "note.notespecial",
"pk": 1,
"fields": {
"special_type": "Esp\u00e8ces"
}
},
{
"model": "note.notespecial",
"pk": 2,
"fields": {
"special_type": "Carte bancaire"
}
},
{
"model": "note.notespecial",
"pk": 3,
"fields": {
"special_type": "Ch\u00e8que"
}
},
{
"model": "note.notespecial",
"pk": 4,
"fields": {
"special_type": "Virement bancaire"
}
},
{
"model": "note.alias",
"pk": 1,
"fields": {
"name": "Esp\u00e8ces",
"normalized_name": "especes",
"note": 1
}
},
{
"model": "note.alias",
"pk": 2,
"fields": {
"name": "Carte bancaire",
"normalized_name": "cartebancaire",
"note": 2
}
},
{
"model": "note.alias",
"pk": 3,
"fields": {
"name": "Ch\u00e8que",
"normalized_name": "cheque",
"note": 3
}
},
{
"model": "note.alias",
"pk": 4,
"fields": {
"name": "Virement bancaire",
"normalized_name": "virementbancaire",
"note": 4
}
},
{
"model": "note.alias",
"pk": 5,
"fields": {
"name": "BDE",
"normalized_name": "bde",
"note": 5
}
},
{
"model": "note.alias",
"pk": 6,
"fields": {
"name": "Kfet",
"normalized_name": "kfet",
"note": 6
}
}
]

View File

@ -1,22 +1,15 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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 django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.forms import CheckboxSelectMultiple
from django.utils.timezone import make_aware
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.inputs import Autocomplete, AmountInput from note_kfet.inputs import Autocomplete, AmountInput, DateTimePickerInput
from .models import TransactionTemplate, NoteClub from .models import TransactionTemplate, NoteClub, Alias
class ImageForm(forms.Form):
image = forms.ImageField(required=False,
label=_('select an image'),
help_text=_('Maximal size: 2MB'))
x = forms.FloatField(widget=forms.HiddenInput())
y = forms.FloatField(widget=forms.HiddenInput())
width = forms.FloatField(widget=forms.HiddenInput())
height = forms.FloatField(widget=forms.HiddenInput())
class TransactionTemplateForm(forms.ModelForm): class TransactionTemplateForm(forms.ModelForm):
@ -38,3 +31,80 @@ class TransactionTemplateForm(forms.ModelForm):
), ),
'amount': AmountInput(), 'amount': AmountInput(),
} }
class SearchTransactionForm(forms.Form):
source = forms.ModelChoiceField(
queryset=Alias.objects.all(),
label=_("Source"),
required=False,
widget=Autocomplete(
Alias,
resetable=True,
attrs={
'api_url': '/api/note/alias/',
'placeholder': 'Note ...',
},
),
)
destination = forms.ModelChoiceField(
queryset=Alias.objects.all(),
label=_("Destination"),
required=False,
widget=Autocomplete(
Alias,
resetable=True,
attrs={
'api_url': '/api/note/alias/',
'placeholder': 'Note ...',
},
),
)
type = forms.ModelMultipleChoiceField(
queryset=ContentType.objects.filter(app_label="note", model__endswith="transaction"),
initial=ContentType.objects.filter(app_label="note", model__endswith="transaction"),
label=_("Type"),
required=False,
widget=CheckboxSelectMultiple(),
)
reason = forms.CharField(
label=_("Reason"),
required=False,
)
valid = forms.BooleanField(
label=_("Valid"),
initial=False,
required=False,
)
amount_gte = forms.Field(
label=_("Total amount greater than"),
initial=0,
required=False,
widget=AmountInput(),
)
amount_lte = forms.Field(
initial=2 ** 31 - 1,
label=_("Total amount less than"),
required=False,
widget=AmountInput(),
)
created_after = forms.DateTimeField(
label=_("Created after"),
initial=make_aware(datetime(year=2000, month=1, day=1, hour=0, minute=0)),
required=False,
widget=DateTimePickerInput(),
)
created_before = forms.DateTimeField(
label=_("Created before"),
initial=make_aware(datetime(year=2042, month=12, day=31, hour=21, minute=42)),
required=False,
widget=DateTimePickerInput(),
)

View File

@ -0,0 +1,209 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('member', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('balance', models.BigIntegerField(default=0, help_text='in centimes, money credited for this instance', verbose_name='account balance')),
('last_negative', models.DateTimeField(blank=True, help_text='last time the balance was negative', null=True, verbose_name='last negative date')),
('display_image', models.ImageField(default='pic/default.png', max_length=255, upload_to='pic/', verbose_name='display image')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created at')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this note should be treated as active. Unselect this instead of deleting notes.', verbose_name='active')),
('inactivity_reason', models.CharField(choices=[('manual', 'The user blocked his/her note manually, eg. when he/she left the school for holidays. It can be reactivated at any time.'), ('forced', "The note is blocked by the the BDE and can't be manually reactivated.")], default=None, max_length=255, null=True)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_note.note_set+', to='contenttypes.ContentType')),
],
options={
'verbose_name': 'note',
'verbose_name_plural': 'notes',
},
),
migrations.CreateModel(
name='TemplateCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=31, unique=True, verbose_name='name')),
],
options={
'verbose_name': 'transaction category',
'verbose_name_plural': 'transaction categories',
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_alias', models.CharField(default='', max_length=255, verbose_name='used alias')),
('destination_alias', models.CharField(default='', max_length=255, verbose_name='used alias')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created at')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='quantity')),
('amount', models.PositiveIntegerField(verbose_name='amount')),
('reason', models.CharField(max_length=255, verbose_name='reason')),
('valid', models.BooleanField(default=True, verbose_name='valid')),
('invalidity_reason', models.CharField(blank=True, default=None, max_length=255, null=True, verbose_name='invalidity reason')),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='note.Note', verbose_name='destination')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_note.transaction_set+', to='contenttypes.ContentType')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='note.Note', verbose_name='source')),
],
options={
'verbose_name': 'transaction',
'verbose_name_plural': 'transactions',
},
),
migrations.CreateModel(
name='MembershipTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
],
options={
'verbose_name': 'membership transaction',
'verbose_name_plural': 'membership transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='NoteClub',
fields=[
('note_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Note')),
],
options={
'verbose_name': 'club note',
'verbose_name_plural': 'clubs notes',
},
bases=('note.note',),
),
migrations.CreateModel(
name='NoteSpecial',
fields=[
('note_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Note')),
('special_type', models.CharField(max_length=255, unique=True, verbose_name='type')),
],
options={
'verbose_name': 'special note',
'verbose_name_plural': 'special notes',
},
bases=('note.note',),
),
migrations.CreateModel(
name='NoteUser',
fields=[
('note_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Note')),
],
options={
'verbose_name': "one's note",
'verbose_name_plural': 'users note',
},
bases=('note.note',),
),
migrations.CreateModel(
name='RecurrentTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
],
options={
'verbose_name': 'recurrent transaction',
'verbose_name_plural': 'recurrent transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='SpecialTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
('last_name', models.CharField(max_length=255, verbose_name='name')),
('first_name', models.CharField(max_length=255, verbose_name='first_name')),
('bank', models.CharField(blank=True, max_length=255, verbose_name='bank')),
],
options={
'verbose_name': 'Special transaction',
'verbose_name_plural': 'Special transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='Alias',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('normalized_name', models.CharField(editable=False, max_length=255, unique=True)),
('note', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.Note')),
],
options={
'verbose_name': 'alias',
'verbose_name_plural': 'aliases',
},
),
migrations.CreateModel(
name='TransactionTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(error_messages={'unique': 'A template with this name already exist'}, max_length=255, unique=True, verbose_name='name')),
('amount', models.PositiveIntegerField(verbose_name='amount')),
('display', models.BooleanField(default=True, verbose_name='display')),
('highlighted', models.BooleanField(default=False, verbose_name='highlighted')),
('description', models.CharField(blank=True, max_length=255, verbose_name='description')),
('category', models.ForeignKey(max_length=31, on_delete=django.db.models.deletion.PROTECT, related_name='templates', to='note.TemplateCategory', verbose_name='type')),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='note.NoteClub', verbose_name='destination')),
],
options={
'verbose_name': 'transaction template',
'verbose_name_plural': 'transaction templates',
},
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['created_at'], name='note_transa_created_bea8b1_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['source'], name='note_transa_source__4a1a1e_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['destination'], name='note_transa_destina_6e1bb4_idx'),
),
migrations.AddField(
model_name='recurrenttransaction',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.TransactionTemplate'),
),
migrations.AddField(
model_name='noteuser',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='note', to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='noteclub',
name='club',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='note', to='member.Club', verbose_name='club'),
),
migrations.AddField(
model_name='membershiptransaction',
name='membership',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='member.Membership'),
),
migrations.AddIndex(
model_name='alias',
index=models.Index(fields=['name'], name='note_alias_name_a89405_idx'),
),
migrations.AddIndex(
model_name='alias',
index=models.Index(fields=['normalized_name'], name='note_alias_normali_bd52b4_idx'),
),
]

View File

@ -0,0 +1,25 @@
from django.db import migrations
def create_special_notes(apps, schema_editor):
"""
We create the four special note to make transfers.
"""
NoteSpecial = apps.get_model("note", "notespecial")
ContentType = apps.get_model('contenttypes', 'ContentType')
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteSpecial).id
NoteSpecial.objects.get_or_create(id=1, special_type="Espèces", polymorphic_ctype_id=polymorphic_ctype_id)
NoteSpecial.objects.get_or_create(id=2, special_type="Carte bancaire", polymorphic_ctype_id=polymorphic_ctype_id)
NoteSpecial.objects.get_or_create(id=3, special_type="Chèque", polymorphic_ctype_id=polymorphic_ctype_id)
NoteSpecial.objects.get_or_create(id=4, special_type="Virement bancaire", polymorphic_ctype_id=polymorphic_ctype_id)
class Migration(migrations.Migration):
dependencies = [
('note', '0001_initial'),
]
operations = [
migrations.RunPython(create_special_notes),
]

View File

@ -0,0 +1,17 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('note', '0002_create_special_notes'),
]
operations = [
migrations.RunSQL(
"UPDATE note_note SET inactivity_reason = '' WHERE inactivity_reason IS NULL;"
),
migrations.RunSQL(
"UPDATE note_transaction SET invalidity_reason = '' WHERE invalidity_reason IS NULL;"
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.16 on 2020-09-06 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('note', '0003_replace_null_by_blank'),
]
operations = [
migrations.AlterField(
model_name='note',
name='inactivity_reason',
field=models.CharField(blank=True, choices=[('manual', 'The user blocked his/her note manually, eg. when he/she left the school for holidays. It can be reactivated at any time.'), ('forced', "The note is blocked by the the BDE and can't be manually reactivated.")], default='', max_length=255),
),
migrations.AlterField(
model_name='transaction',
name='invalidity_reason',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='invalidity reason'),
),
]

View File

@ -4,9 +4,12 @@
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.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.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
@ -24,24 +27,20 @@ class Note(PolymorphicModel):
A Note can be searched find throught an :model:`note.Alias` A Note can be searched find throught an :model:`note.Alias`
""" """
balance = models.IntegerField(
balance = models.BigIntegerField(
verbose_name=_('account balance'), verbose_name=_('account balance'),
help_text=_('in centimes, money credited for this instance'), help_text=_('in centimes, money credited for this instance'),
default=0, default=0,
) )
last_negative = models.DateTimeField( last_negative = models.DateTimeField(
verbose_name=_('last negative date'), verbose_name=_('last negative date'),
help_text=_('last time the balance was negative'), help_text=_('last time the balance was negative'),
null=True, null=True,
blank=True, blank=True,
) )
is_active = models.BooleanField(
_('active'),
default=True,
help_text=_(
'Designates whether this note should be treated as active. '
'Unselect this instead of deleting notes.'),
)
display_image = models.ImageField( display_image = models.ImageField(
verbose_name=_('display image'), verbose_name=_('display image'),
max_length=255, max_length=255,
@ -50,11 +49,31 @@ class Note(PolymorphicModel):
upload_to='pic/', upload_to='pic/',
default='pic/default.png' default='pic/default.png'
) )
created_at = models.DateTimeField( created_at = models.DateTimeField(
verbose_name=_('created at'), verbose_name=_('created at'),
default=timezone.now, default=timezone.now,
) )
is_active = models.BooleanField(
_('active'),
default=True,
help_text=_(
'Designates whether this note should be treated as active. '
'Unselect this instead of deleting notes.'),
)
inactivity_reason = models.CharField(
max_length=255,
choices=[
('manual', _("The user blocked his/her note manually, eg. when he/she left the school for holidays. "
"It can be reactivated at any time.")),
('forced', _("The note is blocked by the the BDE and can't be manually reactivated.")),
],
blank=True,
default="",
)
class Meta: class Meta:
verbose_name = _("note") verbose_name = _("note")
verbose_name_plural = _("notes") verbose_name_plural = _("notes")
@ -67,28 +86,40 @@ class Note(PolymorphicModel):
pretty.short_description = _('Note') pretty.short_description = _('Note')
@property
def last_negative_duration(self):
if self.balance >= 0 or self.last_negative is None:
return None
delta = timezone.now() - self.last_negative
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)
""" """
aliases = Alias.objects.filter(name=str(self)) # Check that we can save the alias
if aliases.exists(): self.clean()
# Alias exists, so check if it is linked to this note
if aliases.first().note != self:
raise ValidationError(_('This alias is already taken.'),
code="same_alias")
# Save note
super().save(*args, **kwargs) super().save(*args, **kwargs)
else:
# Alias does not exist yet, so check if it can exist if not Alias.objects.filter(normalized_name=Alias.normalize(str(self))).exists():
a = Alias(name=str(self)) a = Alias(name=str(self))
a.clean() a.clean()
# Save note and alias # Save alias
super().save(*args, **kwargs)
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:
# 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)))
if alias.name != str(self):
alias.name = str(self)
alias._force_save = True
alias.save()
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
""" """
@ -128,6 +159,12 @@ 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 send_mail_negative_balance(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))
self.user.email_user("[Note Kfet] Passage en négatif (compte n°{:d})"
.format(self.user.pk), plain_text, html_message=html)
class NoteClub(Note): class NoteClub(Note):
""" """
@ -150,6 +187,12 @@ 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 send_mail_negative_balance(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))
send_mail("[Note Kfet] Passage en négatif (club {})".format(self.club.name), plain_text, DEFAULT_FROM_EMAIL,
[self.club.email], html_message=html)
class NoteSpecial(Note): class NoteSpecial(Note):
""" """
@ -199,7 +242,7 @@ class Alias(models.Model):
normalized_name = models.CharField( normalized_name = models.CharField(
max_length=255, max_length=255,
unique=True, unique=True,
default='', blank=False,
editable=False, editable=False,
) )
note = models.ForeignKey( note = models.ForeignKey(
@ -221,18 +264,21 @@ class Alias(models.Model):
@staticmethod @staticmethod
def normalize(string): def normalize(string):
""" """
Normalizes a string: removes most diacritics and does casefolding Normalizes a string: removes most diacritics, does casefolding and ignore non-ASCII characters
""" """
return ''.join( return ''.join(
char for char in unicodedata.normalize('NFKD', string.casefold()) char for char in unicodedata.normalize('NFKD', string.casefold().replace('æ', 'ae').replace('œ', 'oe'))
if all(not unicodedata.category(char).startswith(cat) if all(not unicodedata.category(char).startswith(cat)
for cat in {'M', 'P', 'Z', 'C'})).casefold() for cat in {'M', 'Pc', 'Pe', 'Pf', 'Pi', 'Po', 'Ps', 'Z', 'C'}))\
.casefold().encode('ascii', 'ignore').decode('ascii')
def clean(self): def clean(self):
normalized_name = self.normalize(self.name) normalized_name = self.normalize(self.name)
if len(normalized_name) >= 255: if len(normalized_name) >= 255:
raise ValidationError(_('Alias is too long.'), raise ValidationError(_('Alias is too long.'),
code='alias_too_long') code='alias_too_long')
if not normalized_name:
raise ValidationError(_('This alias contains only complex character. Please use a more simple alias.'))
try: try:
sim_alias = Alias.objects.get(normalized_name=normalized_name) sim_alias = Alias.objects.get(normalized_name=normalized_name)
if self != sim_alias: if self != sim_alias:
@ -243,8 +289,9 @@ 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.normalized_name = self.normalize(self.name) self.clean()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):

View File

@ -1,7 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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
from django.db import models from django.db import models, transaction
from django.urls import reverse from django.urls import reverse
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,7 +58,6 @@ class TransactionTemplate(models.Model):
amount = models.PositiveIntegerField( amount = models.PositiveIntegerField(
verbose_name=_('amount'), verbose_name=_('amount'),
help_text=_('in centimes'),
) )
category = models.ForeignKey( category = models.ForeignKey(
TemplateCategory, TemplateCategory,
@ -90,6 +90,9 @@ class TransactionTemplate(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('note:template_update', args=(self.pk,)) return reverse('note:template_update', args=(self.pk,))
def __str__(self):
return self.name
class Transaction(PolymorphicModel): class Transaction(PolymorphicModel):
""" """
@ -150,8 +153,7 @@ class Transaction(PolymorphicModel):
invalidity_reason = models.CharField( invalidity_reason = models.CharField(
verbose_name=_('invalidity reason'), verbose_name=_('invalidity reason'),
max_length=255, max_length=255,
default=None, default='',
null=True,
blank=True, blank=True,
) )
@ -164,14 +166,64 @@ class Transaction(PolymorphicModel):
models.Index(fields=['destination']), models.Index(fields=['destination']),
] ]
def validate(self):
previous_source_balance = self.source.balance
previous_dest_balance = self.destination.balance
source_balance = previous_source_balance
dest_balance = previous_dest_balance
created = self.pk is None
to_transfer = self.total
if not created:
# Revert old transaction
# 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
if not hasattr(self, "_force_save"):
for field_name in ["source_id", "destination_id", "quantity", "amount"]:
if getattr(self, field_name) != getattr(old_transaction, field_name):
raise ValidationError(_("You can't update the {field} on a Transaction. "
"Please invalidate it and create one other.").format(field=field_name))
if old_transaction.valid == self.valid:
# Don't change anything
return 0, 0
if old_transaction.valid:
source_balance += to_transfer
dest_balance -= to_transfer
if self.valid:
source_balance -= to_transfer
dest_balance += to_transfer
# When a transaction is declared valid, we ensure that the invalidity reason is null, if it was
# previously invalid
self.invalidity_reason = ""
if source_balance > 9223372036854775807 or source_balance < -9223372036854775808\
or dest_balance > 9223372036854775807 or dest_balance < -9223372036854775808:
raise ValidationError(_("The note balances must be between - 92 233 720 368 547 758.08 € "
"and 92 233 720 368 547 758.07 €."))
return source_balance - previous_source_balance, dest_balance - previous_dest_balance
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
When saving, also transfer money between two notes When saving, also transfer money between two notes
""" """
if self.source.pk == self.destination.pk:
# When source == destination, no money is transferred and no transaction is created
return
self.source = Note.objects.select_for_update().get(pk=self.source_id)
self.destination = Note.objects.select_for_update().get(pk=self.destination_id)
# Check that the amounts stay between big integer bounds
diff_source, diff_dest = self.validate()
if not self.source.is_active or not self.destination.is_active: if not self.source.is_active or not self.destination.is_active:
if 'force_insert' not in kwargs or not kwargs['force_insert']:
if 'force_update' not in kwargs or not kwargs['force_update']:
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."))
@ -182,45 +234,19 @@ class Transaction(PolymorphicModel):
if not self.destination_alias: if not self.destination_alias:
self.destination_alias = str(self.destination) self.destination_alias = str(self.destination)
if self.source.pk == self.destination.pk:
# When source == destination, no money is transferred
super().save(*args, **kwargs)
return
created = self.pk is None
to_transfer = self.amount * self.quantity
if not created:
# Revert old transaction
old_transaction = Transaction.objects.get(pk=self.pk)
if old_transaction.valid:
self.source.balance += to_transfer
self.destination.balance -= to_transfer
if self.valid:
self.source.balance -= to_transfer
self.destination.balance += to_transfer
# When a transaction is declared valid, we ensure that the invalidity reason is null, if it was
# previously invalid
self.invalidity_reason = None
# We save first the transaction, in case of the user has no right to transfer money # We save first the transaction, in case of the user has no right to transfer money
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Save notes # Save notes
self.source.refresh_from_db()
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._force_save = True self.destination._force_save = True
self.destination.save() self.destination.save()
def delete(self, **kwargs):
"""
Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first.
"""
self.valid = False
self.save(**kwargs)
super().delete(**kwargs)
@property @property
def total(self): def total(self):
return self.amount * self.quantity return self.amount * self.quantity
@ -243,15 +269,26 @@ class RecurrentTransaction(Transaction):
TransactionTemplate, TransactionTemplate,
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
category = models.ForeignKey(
TemplateCategory, def clean(self):
on_delete=models.PROTECT, if self.template.destination != self.destination:
) raise ValidationError(
_("The destination of this transaction must equal to the destination of the template."))
return super().clean()
@transaction.atomic
def save(self, *args, **kwargs):
self.clean()
return super().save(*args, **kwargs)
@property @property
def type(self): def type(self):
return _('Template') return _('Template')
class Meta:
verbose_name = _("recurrent transaction")
verbose_name_plural = _("recurrent transactions")
class SpecialTransaction(Transaction): class SpecialTransaction(Transaction):
""" """
@ -290,6 +327,15 @@ 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):
self.clean()
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Special transaction")
verbose_name_plural = _("Special transactions")
class MembershipTransaction(Transaction): class MembershipTransaction(Transaction):
""" """
@ -309,4 +355,4 @@ class MembershipTransaction(Transaction):
@property @property
def type(self): def type(self):
return _('membership transaction') return _('membership').capitalize()

View File

@ -1,16 +1,15 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 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):
""" """
Hook to create and save a note when an user is updated Hook to create and save a note when an user is updated
""" """
if raw: if not raw and (instance.is_superuser or instance.profile.registration_valid)\
# When provisionning data, do not try to autocreate and not hasattr(instance, "_no_signal"):
return
if instance.is_superuser or instance.profile.registration_valid:
# Create note only when the registration is validated # Create note only when the registration is validated
from note.models import NoteUser from note.models import NoteUser
NoteUser.objects.get_or_create(user=instance) NoteUser.objects.get_or_create(user=instance)
@ -21,10 +20,27 @@ def save_club_note(instance, raw, **_kwargs):
""" """
Hook to create and save a note when a club is updated Hook to create and save a note when a club is updated
""" """
if raw:
# When provisionning data, do not try to autocreate # When provisionning data, do not try to autocreate
return if not raw and not hasattr(instance, "_no_signal"):
from .models import NoteClub from .models import NoteClub
NoteClub.objects.get_or_create(club=instance) NoteClub.objects.get_or_create(club=instance)
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):
"""
Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first.
"""
if not hasattr(instance, "_no_signal"):
instance.valid = False
instance.save()

View File

@ -0,0 +1,262 @@
// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
// SPDX-License-Identifier: GPL-3.0-or-later
// When a transaction is performed, lock the interface to prevent spam clicks.
var LOCK = false
/**
* Refresh the history table on the consumptions page.
*/
function refreshHistory () {
$('#history').load('/note/consos/ #history')
$('#most_used').load('/note/consos/ #most_used')
}
$(document).ready(function () {
// If hash of a category in the URL, then select this category
// else select the first one
if (location.hash) {
$("a[href='" + location.hash + "']").tab('show')
} else {
$("a[data-toggle='tab']").first().tab('show')
}
// When selecting a category, change URL
$(document.body).on('click', "a[data-toggle='tab']", function () {
location.hash = this.getAttribute('href')
})
// Switching in double consumptions mode should update the layout
$('#double_conso').change(function () {
$('#consos_list_div').removeClass('d-none')
$('#infos_div').attr('class', 'col-sm-5 col-xl-6')
const note_list_obj = $('#note_list')
if (buttons.length > 0 && note_list_obj.text().length > 0) {
$('#consos_list').html(note_list_obj.html())
note_list_obj.html('')
buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, 'consos_list')()
})
})
}
})
$('#single_conso').change(function () {
$('#consos_list_div').addClass('d-none')
$('#infos_div').attr('class', 'col-sm-5 col-md-4')
const consos_list_obj = $('#consos_list')
if (buttons.length > 0) {
if (notes_display.length === 0 && consos_list_obj.text().length > 0) {
$('#note_list').html(consos_list_obj.html())
consos_list_obj.html('')
buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, 'note_list')()
})
})
} else {
buttons.length = 0
consos_list_obj.html('')
}
}
})
// Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS
$("label[for='double_conso']").removeClass('active')
$('#consume_all').click(consumeAll)
})
notes = []
notes_display = []
buttons = []
// When the user searches an alias, we update the auto-completion
autoCompleteNote('note', 'note_list', notes, notes_display,
'alias', 'note', 'user_note', 'profile_pic', function () {
if (buttons.length > 0 && $('#single_conso').is(':checked')) {
consumeAll()
return false
}
return true
})
/**
* Add a transaction from a button.
* @param dest Where the money goes
* @param amount The price of the item
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category_id The category identifier
* @param category_name The category name
* @param template_id The identifier of the button
* @param template_name The name of the button
*/
function addConso (dest, amount, type, category_id, category_name, template_id, template_name) {
var button = null
buttons.forEach(function (b) {
if (b.id === template_id) {
b.quantity += 1
button = b
}
})
if (button == null) {
button = {
id: template_id,
name: template_name,
dest: dest,
quantity: 1,
amount: amount,
type: type,
category_id: category_id,
category_name: category_name
}
buttons.push(button)
}
const dc_obj = $('#double_conso')
if (dc_obj.is(':checked') || notes_display.length === 0) {
const list = dc_obj.is(':checked') ? 'consos_list' : 'note_list'
let html = ''
buttons.forEach(function (button) {
html += li('conso_button_' + button.id, button.name +
'<span class="badge badge-dark badge-pill">' + button.quantity + '</span>')
})
$('#' + list).html(html)
buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, list)()
})
})
} else { consumeAll() }
}
/**
* Reset the page as its initial state.
*/
function reset () {
notes_display.length = 0
notes.length = 0
buttons.length = 0
$('#note_list').html('')
$('#consos_list').html('')
$('#note').val('')
$('#note').attr('data-original-title', '').tooltip('hide')
$('#profile_pic').attr('src', '/static/member/img/default_picture.png')
$('#profile_pic_link').attr('href', '#')
refreshHistory()
refreshBalance()
LOCK = false
}
/**
* Apply all transactions: all notes in `notes` buy each item in `buttons`
*/
function consumeAll () {
if (LOCK) { return }
LOCK = true
let error = false
if (notes_display.length === 0) {
$('#note').addClass('is-invalid')
$('#note_list').html(li('', '<strong>Ajoutez des émetteurs.</strong>', 'text-danger'))
error = true
}
if (buttons.length === 0) {
$('#consos_list').html(li('', '<strong>Ajoutez des consommations.</strong>', 'text-danger'))
error = true
}
if (error) {
LOCK = false
return
}
notes_display.forEach(function (note_display) {
buttons.forEach(function (button) {
consume(note_display.note, note_display.name, button.dest, button.quantity * note_display.quantity, button.amount,
button.name + ' (' + button.category_name + ')', button.type, button.category_id, button.id)
})
})
}
/**
* Create a new transaction from a button through the API.
* @param source The note that paid the item (type: note)
* @param source_alias The alias used for the source (type: str)
* @param dest The note that sold the item (type: int)
* @param quantity The quantity sold (type: int)
* @param amount The price of one item, in cents (type: int)
* @param reason The transaction details (type: str)
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category The category id of the button (type: int)
* @param template The button id (type: int)
*/
function consume (source, source_alias, dest, quantity, amount, reason, type, category, template) {
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: quantity,
amount: amount,
reason: reason,
valid: true,
polymorphic_ctype: type,
resourcetype: 'RecurrentTransaction',
source: source.id,
source_alias: source_alias,
destination: dest,
template: template
})
.done(function () {
if (!isNaN(source.balance)) {
const newBalance = source.balance - quantity * amount
if (newBalance <= -5000) {
addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' +
'succès, mais la note émettrice ' + source_alias + ' est en négatif sévère.',
'danger', 30000)
} else if (newBalance < 0) {
addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' +
'succès, mais la note émettrice ' + source_alias + ' est en négatif.',
'warning', 30000)
}
if (source.membership && source.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.",
'danger', 30000)
}
}
reset()
}).fail(function (e) {
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: quantity,
amount: amount,
reason: reason,
valid: false,
invalidity_reason: 'Solde insuffisant',
polymorphic_ctype: type,
resourcetype: 'RecurrentTransaction',
source: source,
source_alias: source_alias,
destination: dest,
template: template
}).done(function () {
reset()
addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", 'danger', 10000)
}).fail(function () {
reset()
errMsg(e.responseJSON)
})
})
}

View File

@ -0,0 +1,426 @@
var LOCK = false
sources = []
sources_notes_display = []
dests = []
dests_notes_display = []
function refreshHistory () {
$('#history').load('/note/transfer/ #history')
}
function reset (refresh = true) {
sources_notes_display.length = 0
sources.length = 0
dests_notes_display.length = 0
dests.length = 0
$('#source_note_list').html('')
$('#dest_note_list').html('')
const source_field = $('#source_note')
source_field.val('')
const event = jQuery.Event('keyup')
event.originalEvent = { charCode: 97 }
source_field.trigger(event)
source_field.removeClass('is-invalid')
source_field.attr('data-original-title', '').tooltip('hide')
const dest_field = $('#dest_note')
dest_field.val('')
dest_field.trigger(event)
dest_field.removeClass('is-invalid')
dest_field.attr('data-original-title', '').tooltip('hide')
const amount_field = $('#amount')
amount_field.val('')
amount_field.removeClass('is-invalid')
$('#amount-required').html('')
const reason_field = $('#reason')
reason_field.val('')
reason_field.removeClass('is-invalid')
$('#reason-required').html('')
$('#last_name').val('')
$('#first_name').val('')
$('#bank').val('')
$('#user_note').val('')
$('#profile_pic').attr('src', '/static/member/img/default_picture.png')
$('#profile_pic_link').attr('href', '#')
if (refresh) {
refreshBalance()
refreshHistory()
}
LOCK = false
}
$(document).ready(function () {
/**
* If we are in credit/debit mode, check that only one note is entered.
* More over, get first name and last name to autocomplete fields.
*/
function checkUniqueNote () {
if ($('#type_credit').is(':checked') || $('#type_debit').is(':checked')) {
const arr = $('#type_credit').is(':checked') ? dests_notes_display : sources_notes_display
if (arr.length === 0) { return }
const last = arr[arr.length - 1]
arr.length = 0
arr.push(last)
last.quantity = 1
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) {
last.note.user = note.user
$.getJSON('/api/user/' + last.note.user + '/', function (user) {
$('#last_name').val(user.last_name)
$('#first_name').val(user.first_name)
})
})
} else {
$.getJSON('/api/user/' + last.note.user + '/', function (user) {
$('#last_name').val(user.last_name)
$('#first_name').val(user.first_name)
})
}
}
return true
}
autoCompleteNote('source_note', 'source_note_list', sources, sources_notes_display,
'source_alias', 'source_note', 'user_note', 'profile_pic', checkUniqueNote)
autoCompleteNote('dest_note', 'dest_note_list', dests, dests_notes_display,
'dest_alias', 'dest_note', 'user_note', 'profile_pic', checkUniqueNote)
const source = $('#source_note')
const dest = $('#dest_note')
$('#type_transfer').change(function () {
if (LOCK) { return }
$('#source_me_div').removeClass('d-none')
$('#source_note').removeClass('is-invalid')
$('#dest_note').removeClass('is-invalid')
$('#special_transaction_div').addClass('d-none')
source.removeClass('d-none')
$('#source_note_list').removeClass('d-none')
$('#credit_type').addClass('d-none')
dest.removeClass('d-none')
$('#dest_note_list').removeClass('d-none')
$('#debit_type').addClass('d-none')
$('#source_note_label').text(select_emitters_label)
$('#dest_note_label').text(select_receveirs_label)
location.hash = 'transfer'
})
$('#type_credit').change(function () {
if (LOCK) { return }
$('#source_me_div').addClass('d-none')
$('#source_note').removeClass('is-invalid')
$('#dest_note').removeClass('is-invalid')
$('#special_transaction_div').removeClass('d-none')
$('#source_note_list').addClass('d-none')
$('#dest_note_list').removeClass('d-none')
source.addClass('d-none')
source.tooltip('hide')
$('#credit_type').removeClass('d-none')
dest.removeClass('d-none')
dest.val('')
dest.tooltip('hide')
$('#debit_type').addClass('d-none')
$('#source_note_label').text(transfer_type_label)
$('#dest_note_label').text(select_receveir_label)
if (dests_notes_display.length > 1) {
$('#dest_note_list').html('')
dests_notes_display.length = 0
}
location.hash = 'credit'
})
$('#type_debit').change(function () {
if (LOCK) { return }
$('#source_me_div').addClass('d-none')
$('#source_note').removeClass('is-invalid')
$('#dest_note').removeClass('is-invalid')
$('#special_transaction_div').removeClass('d-none')
$('#source_note_list').removeClass('d-none')
$('#dest_note_list').addClass('d-none')
source.removeClass('d-none')
source.val('')
source.tooltip('hide')
$('#credit_type').addClass('d-none')
dest.addClass('d-none')
dest.tooltip('hide')
$('#debit_type').removeClass('d-none')
$('#source_note_label').text(select_emitter_label)
$('#dest_note_label').text(transfer_type_label)
if (sources_notes_display.length > 1) {
$('#source_note_list').html('')
sources_notes_display.length = 0
}
location.hash = 'debit'
})
$('#credit_type').change(function () {
const type = $('#credit_type option:selected').text()
if ($('#type_credit').is(':checked')) { source.val(type) } else { dest.val(type) }
})
// Ensure we begin in transfer mode. Removing these lines may cause problems when reloading.
const type_transfer = $('#type_transfer') // Default mode
type_transfer.removeAttr('checked')
$('#type_credit').removeAttr('checked')
$('#type_debit').removeAttr('checked')
if (location.hash) { $('#type_' + location.hash.substr(1)).click() } else { type_transfer.click() }
$('#source_me').click(function () {
if (LOCK) { return }
// Shortcut to set the current user as the only emitter
sources_notes_display.length = 0
sources.length = 0
$('#source_note_list').html('')
const source_note = $('#source_note')
source_note.focus()
source_note.val('')
let event = jQuery.Event('keyup')
event.originalEvent = { charCode: 97 }
source_note.trigger(event)
source_note.val(username)
event = jQuery.Event('keyup')
event.originalEvent = { charCode: 97 }
source_note.trigger(event)
const fill_note = function () {
if (sources.length === 0) {
setTimeout(fill_note, 100)
return
}
event = jQuery.Event('keypress')
event.originalEvent = { charCode: 13 }
source_note.trigger(event)
source_note.tooltip('hide')
source_note.val('')
$('#dest_note').focus()
}
fill_note()
})
})
$('#btn_transfer').click(function () {
if (LOCK) { return }
LOCK = true
let error = false
const amount_field = $('#amount')
amount_field.removeClass('is-invalid')
$('#amount-required').html('')
const reason_field = $('#reason')
reason_field.removeClass('is-invalid')
$('#reason-required').html('')
if (!amount_field.val() || isNaN(amount_field.val()) || amount_field.val() <= 0) {
amount_field.addClass('is-invalid')
$('#amount-required').html('<strong>Ce champ est requis et doit comporter un nombre décimal strictement positif.</strong>')
error = true
}
const amount = Math.floor(100 * amount_field.val())
if (amount > 2147483647) {
amount_field.addClass('is-invalid')
$('#amount-required').html('<strong>Le montant ne doit pas excéder 21474836.47 €.</strong>')
error = true
}
if (!reason_field.val() && $('#type_transfer').is(':checked')) {
reason_field.addClass('is-invalid')
$('#reason-required').html('<strong>Ce champ est requis.</strong>')
error = true
}
if (!sources_notes_display.length && !$('#type_credit').is(':checked')) {
$('#source_note').addClass('is-invalid')
error = true
}
if (!dests_notes_display.length && !$('#type_debit').is(':checked')) {
$('#dest_note').addClass('is-invalid')
error = true
}
if (error) {
LOCK = false
return
}
let reason = reason_field.val()
if ($('#type_transfer').is(':checked')) {
// We copy the arrays to ensure that transactions are well-processed even if the form is reset
[...sources_notes_display].forEach(function (source) {
[...dests_notes_display].forEach(function (dest) {
if (source.note.id === dest.note.id) {
addMsg('Attention : la transaction de ' + pretty_money(amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + " n'a pas été faite car il s'agit de la même note au départ" +
" et à l'arrivée.", 'warning', 10000)
LOCK = false
return
}
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: source.quantity * dest.quantity,
amount: amount,
reason: reason,
valid: true,
polymorphic_ctype: TRANSFER_POLYMORPHIC_CTYPE,
resourcetype: 'Transaction',
source: source.note.id,
source_alias: source.name,
destination: dest.note.id,
destination_alias: dest.name
}).done(function () {
if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.",
'danger', 30000)
}
if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note destination ' + dest.name + " n'est plus adhérente.",
'danger', 30000)
}
if (!isNaN(source.note.balance)) {
const newBalance = source.note.balance - source.quantity * dest.quantity * amount
if (newBalance <= -5000) {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' +
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()
return
} else if (newBalance < 0) {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' +
source.name + ' vers la note ' + dest.name + ' a été fait avec succès, ' +
'mais la note émettrice est en négatif.', 'warning', 10000)
reset()
return
}
}
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a été fait avec succès !', 'success', 10000)
reset()
}).fail(function (err) { // do it again but valid = false
const errObj = JSON.parse(err.responseText)
if (errObj.non_field_errors) {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a échoué : ' + errObj.non_field_errors, 'danger')
LOCK = false
return
}
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: source.quantity * dest.quantity,
amount: amount,
reason: reason,
valid: false,
invalidity_reason: 'Solde insuffisant',
polymorphic_ctype: TRANSFER_POLYMORPHIC_CTYPE,
resourcetype: 'Transaction',
source: source.note.id,
source_alias: source.name,
destination: dest.note.id,
destination_alias: dest.name
}).done(function () {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a échoué : Solde insuffisant', 'danger', 10000)
reset()
}).fail(function (err) {
const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText }
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a échoué : ' + error, 'danger')
LOCK = false
})
})
})
})
} else if ($('#type_credit').is(':checked') || $('#type_debit').is(':checked')) {
let special_note
let user_note
let alias
const given_reason = reason
let source_id, dest_id
if ($('#type_credit').is(':checked')) {
special_note = $('#credit_type').val()
user_note = dests_notes_display[0].note
alias = dests_notes_display[0].name
source_id = special_note
dest_id = user_note.id
reason = 'Crédit ' + $('#credit_type option:selected').text().toLowerCase()
if (given_reason.length > 0) { reason += ' (' + given_reason + ')' }
} else {
special_note = $('#debit_type').val()
user_note = sources_notes_display[0].note
alias = sources_notes_display[0].name
source_id = user_note.id
dest_id = special_note
reason = 'Retrait ' + $('#debit_type option:selected').text().toLowerCase()
if (given_reason.length > 0) { reason += ' (' + given_reason + ')' }
}
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: 1,
amount: amount,
reason: reason,
valid: true,
polymorphic_ctype: SPECIAL_TRANSFER_POLYMORPHIC_CTYPE,
resourcetype: 'SpecialTransaction',
source: source_id,
source_alias: sources_notes_display.length ? alias : null,
destination: dest_id,
destination_alias: dests_notes_display.length ? alias : null,
last_name: $('#last_name').val(),
first_name: $('#first_name').val(),
bank: $('#bank').val()
}).done(function () {
addMsg('Le crédit/retrait a bien été effectué !', '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) }
reset()
}).fail(function (err) {
const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText }
addMsg('Le crédit/retrait a échoué : ' + error, 'danger', 10000)
LOCK = false
})
}
})

View File

@ -4,7 +4,6 @@
import html import html
import django_tables2 as tables import django_tables2 as tables
from django.db.models import F
from django.utils.html import format_html from django.utils.html import format_html
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 _
@ -19,8 +18,7 @@ from .templatetags.pretty_money import pretty_money
class HistoryTable(tables.Table): class HistoryTable(tables.Table):
class Meta: class Meta:
attrs = { attrs = {
'class': 'class': 'table table-condensed table-striped'
'table table-condensed table-striped table-hover'
} }
model = Transaction model = Transaction
exclude = ("id", "polymorphic_ctype", "invalidity_reason", "source_alias", "destination_alias",) exclude = ("id", "polymorphic_ctype", "invalidity_reason", "source_alias", "destination_alias",)
@ -31,6 +29,7 @@ class HistoryTable(tables.Table):
source = tables.Column( source = tables.Column(
attrs={ attrs={
"td": { "td": {
"class": "text-nowrap",
"data-toggle": "tooltip", "data-toggle": "tooltip",
"title": lambda record: _("used alias").capitalize() + " : " + record.source_alias, "title": lambda record: _("used alias").capitalize() + " : " + record.source_alias,
} }
@ -40,15 +39,47 @@ class HistoryTable(tables.Table):
destination = tables.Column( destination = tables.Column(
attrs={ attrs={
"td": { "td": {
"class": "text-nowrap",
"data-toggle": "tooltip", "data-toggle": "tooltip",
"title": lambda record: _("used alias").capitalize() + " : " + record.destination_alias, "title": lambda record: _("used alias").capitalize() + " : " + record.destination_alias,
} }
} }
) )
created_at = tables.DateTimeColumn(
format='Y-m-d H:i:s',
attrs={
"td": {
"class": "text-nowrap",
},
}
)
amount = tables.Column(
attrs={
"td": {
"class": "text-nowrap",
},
}
)
reason = tables.Column(
attrs={
"td": {
"class": "text-break",
},
}
)
type = tables.Column() type = tables.Column()
total = tables.Column() # will use Transaction.total() !! total = tables.Column( # will use Transaction.total() !!
attrs={
"td": {
"class": "text-nowrap",
},
}
)
valid = tables.Column( valid = tables.Column(
attrs={ attrs={
@ -56,34 +87,27 @@ class HistoryTable(tables.Table):
"id": lambda record: "validate_" + str(record.id), "id": lambda record: "validate_" + str(record.id),
"class": lambda record: "class": lambda record:
str(record.valid).lower() str(record.valid).lower()
+ (' validate' if PermissionBackend.check_perm(get_current_authenticated_user(), + (' validate' if record.source.is_active and record.destination.is_active and PermissionBackend
"note.change_transaction_invalidity_reason", .check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record)
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_authenticated_user(),
"note.change_transaction_invalidity_reason", record) else None, "note.change_transaction_invalidity_reason", record)
"onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower() + ')' and record.source.is_active and record.destination.is_active else None,
"onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower()
+ ', "' + str(record.__class__.__name__) + '")'
if PermissionBackend.check_perm(get_current_authenticated_user(), if PermissionBackend.check_perm(get_current_authenticated_user(),
"note.change_transaction_invalidity_reason", record) else None, "note.change_transaction_invalidity_reason", record)
and record.source.is_active and record.destination.is_active else None,
"onmouseover": lambda record: '$("#invalidity_reason_' "onmouseover": lambda record: '$("#invalidity_reason_'
+ str(record.id) + '").show();$("#invalidity_reason_' + str(record.id) + '").show();$("#invalidity_reason_'
+ str(record.id) + '").focus();' + str(record.id) + '").focus();',
if PermissionBackend.check_perm(get_current_authenticated_user(), "onmouseout": lambda record: '$("#invalidity_reason_' + str(record.id) + '").hide()',
"note.change_transaction_invalidity_reason", record) else None,
"onmouseout": lambda record: '$("#invalidity_reason_' + str(record.id) + '").hide()'
if PermissionBackend.check_perm(get_current_authenticated_user(),
"note.change_transaction_invalidity_reason", record) else None,
} }
} }
) )
def order_total(self, queryset, is_descending):
# needed for rendering
queryset = queryset.annotate(total=F('amount') * F('quantity')) \
.order_by(('-' if is_descending else '') + 'total')
return queryset, True
def render_amount(self, value): def render_amount(self, value):
return pretty_money(value) return pretty_money(value)
@ -101,15 +125,18 @@ 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 \
.check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record)
val = "" if value else "" val = "" if value else ""
if not PermissionBackend\
.check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record): if value and not has_perm:
return val return val
val += "<input type='text' class='form-control' id='invalidity_reason_" + str(record.id) \ val += "<input type='text' class='form-control' id='invalidity_reason_" + str(record.id) \
+ "' value='" + (html.escape(record.invalidity_reason) + "' value='" + (html.escape(record.invalidity_reason)
if record.invalidity_reason else ("" if value else str(_("No reason specified")))) \ if record.invalidity_reason else ("" if value else str(_("No reason specified")))) \
+ "'" + ("" if value else " disabled") \ + "'" + ("" if value and record.source.is_active and record.destination.is_active else " disabled") \
+ " placeholder='" + html.escape(_("invalidity reason").capitalize()) + "'" \ + " placeholder='" + html.escape(_("invalidity reason").capitalize()) + "'" \
+ " style='position: absolute; width: 15em; margin-left: -15.5em; margin-top: -2em; display: none;'>" + " style='position: absolute; width: 15em; margin-left: -15.5em; margin-top: -2em; display: none;'>"
return format_html(val) return format_html(val)
@ -124,7 +151,7 @@ DELETE_TEMPLATE = """
class AliasTable(tables.Table): class AliasTable(tables.Table):
class Meta: class Meta:
attrs = { attrs = {
'class': 'table table condensed table-striped table-hover', 'class': 'table table condensed table-striped',
'id': "alias_table" 'id': "alias_table"
} }
model = Alias model = Alias
@ -136,15 +163,16 @@ class AliasTable(tables.Table):
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': lambda record: 'col-sm-1' + (
verbose_name=_("Delete"),) ' d-none' if not PermissionBackend.check_perm(
get_current_authenticated_user(), "note.delete_alias",
record) else '')}}, verbose_name=_("Delete"), )
class ButtonTable(tables.Table): class ButtonTable(tables.Table):
class Meta: class Meta:
attrs = { attrs = {
'class': 'class': 'table table-bordered condensed'
'table table-bordered condensed table-hover'
} }
row_attrs = { row_attrs = {
'class': lambda record: 'table-row ' + ('table-success' if record.display else 'table-danger'), 'class': lambda record: 'table-row ' + ('table-success' if record.display else 'table-danger'),
@ -154,18 +182,25 @@ class ButtonTable(tables.Table):
model = TransactionTemplate model = TransactionTemplate
exclude = ('id',) exclude = ('id',)
edit = tables.LinkColumn('note:template_update', edit = tables.LinkColumn(
'note:template_update',
args=[A('pk')], args=[A('pk')],
attrs={'td': {'class': 'col-sm-1'}, attrs={
'a': {'class': 'btn btn-sm btn-primary'}}, 'td': {'class': 'col-sm-1'},
'a': {
'class': 'btn btn-sm btn-primary',
'data-turbolinks': 'false',
}
},
text=_('edit'), text=_('edit'),
accessor='pk', accessor='pk',
verbose_name=_("Edit"),) verbose_name=_("Edit"),
)
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'}},
verbose_name=_("Delete"),) verbose_name=_("Delete"), )
def render_amount(self, value): def render_amount(self, value):
return pretty_money(value) return pretty_money(value)

View File

@ -1,7 +1,13 @@
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{# Select amount to transfert in € #}
<div class="input-group"> <div class="input-group">
<input class="form-control mx-auto d-block" type="number" min="0" step="0.01" <input class="form-control mx-auto d-block" type="number" {% if not widget.attrs.negative %}min="0"{% endif %} step="0.01"
{% if widget.value != None and widget.value != "" %}value="{{ widget.value }}"{% endif %} {% if widget.value != None and widget.value != "" %}value="{{ widget.value }}"{% endif %}
name="{{ widget.name }}" name="{{ widget.name }}"
{# Other attributes are loaded #}
{% for name, value in widget.attrs.items %} {% for name, value in widget.attrs.items %}
{% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %} {% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
{% endfor %}> {% endfor %}>

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