1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-20 17:41:55 +02:00

Compare commits

...

370 Commits

Author SHA1 Message Date
4a4e8769bc Merge branch 'summary_notes' into 'main'
treasury summary

See merge request bde/nk20!223
2025-03-10 20:03:49 +01:00
36f4adf2e7 Merge branch 'update_copyright' into 'main'
update copyright

See merge request bde/nk20!292
2025-03-09 18:35:44 +01:00
ae7d5d5489 update copyright 2025-03-09 18:14:58 +01:00
434097aba4 Merge branch 'nerf_pc_kfet' into 'main'
Nerf pc kfet

See merge request bde/nk20!291
2025-03-09 17:58:25 +01:00
a0ebf8658d nerf invalidate perm 2025-03-09 17:34:14 +01:00
423454ba5d nerf PC Kfet perms 2025-03-09 14:37:35 +01:00
3ccb31639c Merge branch 'perm_gc_anti_vss' into 'main'
improve permissions for GC anti-VSS

See merge request bde/nk20!290
2025-03-09 13:28:55 +01:00
5fb12a1388 improve permissions for GC anti-VSS 2025-03-09 13:11:46 +01:00
fe029893b0 Merge branch 'permissions_fo_parent_clubs' into 'main'
Permissions fo parent clubs

See merge request bde/nk20!289
2025-03-08 22:29:07 +01:00
767e98c2a3 Update file initial.json 2025-03-08 22:05:22 +01:00
1bdad76fe9 Update file initial.json 2025-03-08 22:00:46 +01:00
0196db7fff Update file initial.json 2025-03-08 21:54:28 +01:00
1f53ad4407 Update file initial.json 2025-03-08 21:47:21 +01:00
018f6e3f13 Update file initial.json 2025-03-08 21:37:53 +01:00
9752a030d9 Update file initial.json 2025-03-08 21:30:25 +01:00
b27bdb090d Update file initial.json 2025-03-08 21:30:16 +01:00
55a0fbb6cb Update file initial.json 2025-03-08 21:15:56 +01:00
c356534309 Update file initial.json 2025-03-08 20:25:33 +01:00
51315a0555 Update file initial.json 2025-03-08 20:25:16 +01:00
e5f9fe2cf5 Update file initial.json 2025-03-08 20:00:20 +01:00
c630a3fbd5 Merge branch 'change_template' into 'main'
Change template

See merge request bde/nk20!288
2025-03-07 19:25:27 +01:00
79b8ebeca4 Merge branch 'main' into change_template 2025-03-07 19:09:17 +01:00
dc14ba0101 Suppression article TVA 2025-03-07 18:42:41 +01:00
6028bfeb56 Merge branch 'notekfet_wrapped' into 'main'
Notekfet wrapped

See merge request bde/nk20!287
2025-03-05 22:03:51 +01:00
bd9773a8af change icon 2025-03-05 13:28:55 +01:00
cdeb76d9f8 Merge branch 'main' into notekfet_wrapped 2025-03-04 19:08:32 +01:00
ac4574200d Modify font 2025-03-04 18:45:22 +01:00
b17d31e8ee translation 2025-02-25 14:11:53 +01:00
30d27459dd modify tox.ini to use complex script for make wrapped (bypass C901 in linters) 2025-02-25 01:52:13 +01:00
333f7aa284 update font and minor change 2025-02-24 18:37:18 +01:00
587314e03c linters 2025-02-24 16:10:58 +01:00
9f888a5281 Merge branch 'patch_openers_(forgot_something)' into 'main'
Patch openers (forgot something)

See merge request bde/nk20!286
2025-02-18 21:44:21 +01:00
88b1a25ca0 Update file initial.json 2025-02-18 21:26:55 +01:00
8cb50f58f2 Merge branch 'Respo_jam_permission' into 'main'
Respo jam permission

See merge request bde/nk20!285
2025-02-17 14:48:21 +01:00
041a8f20a9 A permission was missing 2025-02-17 14:28:00 +01:00
b1ffb28532 Update file initial.json 2025-02-17 14:19:00 +01:00
6225fb51f1 Add some permissions 2025-02-17 14:10:21 +01:00
1dd74e8024 Merge branch 'openers' into 'main'
Patch Openers

See merge request bde/nk20!284
2025-02-17 02:13:47 +01:00
1af9f5f23c some updates 2025-02-17 02:12:44 +01:00
83d5a7ceff Update file initial.json 2025-02-17 01:58:13 +01:00
a7cba0a4a3 Update file initial.json 2025-02-16 23:33:18 +01:00
ccd9a66ab9 Update file initial.json 2025-02-16 23:24:39 +01:00
c7a92fa4b2 Update file initial.json 2025-02-16 20:49:11 +01:00
5f1b698d58 Finish script, finish view, make some progress on template 2025-02-16 18:10:53 +01:00
0a5368d23f Merge branch 'respo_comm_permissionsV2' into 'main'
Respo comm permissions v2

See merge request bde/nk20!283
2025-02-14 18:38:39 +01:00
26b351a51c Add another permission for model guest in activity 2025-02-14 18:14:35 +01:00
1836677c47 Update file initial.json 2025-02-13 22:30:36 +01:00
e7a98c86f0 Tried something with permissions 2025-02-13 21:51:26 +01:00
eb5044490b Delete a useless permission 2025-02-13 21:37:58 +01:00
983d7ec052 linters 2025-02-13 21:35:29 +01:00
dc56deaf85 Final modifications 2025-02-13 21:17:57 +01:00
19d1ecfc66 continue the script and few change to model 2025-02-13 02:39:33 +01:00
694f54e1c4 Merge branch 'fix_activity_view' into 'main'
fix issue with activity entry view

See merge request bde/nk20!282
2025-02-12 10:18:33 +01:00
b0c3eee699 start to write generate_wrapped script 2025-02-12 00:00:23 +01:00
cd942779ca Wrapped apps 2025-02-11 18:19:24 +01:00
0d0fdef363 fix issue with activity entry view 2025-02-09 17:58:38 +01:00
7ed544b3ac fix issues with activity entry view 2025-02-09 17:50:15 +01:00
821efbf78b Merge branch 'Automation_mailing_lists' into 'main'
Automation mailing lists

See merge request bde/nk20!280
2025-02-02 14:53:04 +01:00
a209e0d366 Update file forms.py 2025-02-02 14:30:53 +01:00
ef485e0628 Update file forms.py 2025-02-02 14:06:22 +01:00
1481aa0635 Update file forms.py 2025-02-02 14:05:05 +01:00
867bf9fd25 Update file forms.py 2025-02-02 13:33:41 +01:00
47fda0ea36 Update file forms.py 2025-02-02 13:17:19 +01:00
623290827a Update file forms.py 2025-01-27 16:34:45 +01:00
a87ce625f3 Update file note.cron 2025-01-25 13:55:21 +01:00
3559787fa7 Merge branch 'New_permission' into 'main'
New permission

See merge request bde/nk20!278
2025-01-18 15:41:15 +01:00
bd6ed27ae5 Update 2 files
- /apps/permission/fixtures/initial.json
- /apps/permission/admin.py
2025-01-18 15:11:57 +01:00
43dc676747 Update file initial.json 2025-01-18 12:57:42 +01:00
caaeab6b0b Update file initial.json 2025-01-17 19:39:26 +01:00
54ba786884 Update file initial.json 2025-01-17 19:03:59 +01:00
80e109114f Update file initial.json 2025-01-17 18:23:28 +01:00
787005e60d Merge branch 'finito_sda' into 'main'
finitio le message sda

See merge request bde/nk20!279
2025-01-06 00:11:01 +01:00
414e103686 finitio le message sda 2025-01-05 23:17:01 +01:00
942d887c2e Update file initial.json 2024-12-23 18:31:11 +01:00
a63c34fe37 Update file initial.json 2024-12-22 21:38:17 +01:00
2be6133458 Update file initial.json 2024-12-22 20:42:20 +01:00
7975fe47a6 Merge branch 'sda' into 'main'
Donation goal la note kfet x les SdA

See merge request bde/nk20!277
2024-10-10 23:44:22 +02:00
476fbceeea Donation goal la note kfet x les SdA 2024-10-10 01:48:23 +02:00
8fbaa0bdc8 Merge branch 'linters' into 'main'
fix linters for WEI 2024 survey

See merge request bde/nk20!274
2024-10-03 16:51:04 +02:00
a0de63effd Merge branch 'beta' into 'main'
Correction translation of sport events ml

See merge request bde/nk20!276
2024-09-18 13:52:33 +02:00
09fb1d227e Correction translation of sport events ml 2024-09-18 08:54:04 +02:00
2e27d4f05c Merge branch 'non-BDE-members-permission-fix' into 'main'
Added some necessary rights

See merge request bde/nk20!275
2024-09-17 17:24:30 +02:00
5d16dc4e7d Added some necessary rights 2024-09-17 17:13:47 +02:00
3c34033bf5 fix linters for WEI 2024 survey 2024-09-12 13:41:04 +02:00
131f508433 Merge branch 'survey_wei_2024' into 'main'
update hardcoded

See merge request bde/nk20!273
2024-09-12 12:03:10 +02:00
c1a353963a handle hardcoded corrected 2024-09-12 11:36:37 +02:00
178ce2b579 update hardcoded 2024-09-10 22:41:35 +02:00
9162319734 Merge branch 'quark-main-patch-05186' into 'main'
Update views.py (don't display forced blocked note, it's just temporary patch,...

See merge request bde/nk20!272
2024-09-09 21:02:19 +02:00
5d2a8e9b79 Update views.py (don't display forced blocked note, it's just temporary patch, we need to block these note in models too) 2024-09-09 19:05:53 +02:00
33c94d0720 Merge branch 'non-BDE-members' into 'main'
Allow non-BDE members to use the note

See merge request bde/nk20!268
2024-09-05 23:15:04 +02:00
5040e8e8ea Merge branch 'continuous-intergration' into 'main'
continuous-intergration

See merge request bde/nk20!271
2024-09-05 20:54:40 +02:00
c5697c4cb4 don't hide the transfer tab 2024-09-05 20:54:23 +02:00
e188c5a153 Merge branch 'mail' into 'main'
mail

Closes #119

See merge request bde/nk20!270
2024-09-05 20:29:30 +02:00
94e1fdc93a add ubuntu 24.4 in tox.ini and remove debian bullseye in gitlab-ci 2024-09-05 20:19:46 +02:00
d1ef367bab Permissions for child clubs, also changed spaces for tabs 2024-09-05 20:17:45 +02:00
0fbb19c5fd limite mail sending to 10 per minute and purge fail mail log 2024-09-05 19:48:54 +02:00
21cbf2b21a Merge branch 'survey_wei_2024' into 'main'
Survey wei 2024

See merge request bde/nk20!269
2024-08-29 23:10:57 +02:00
185a2cabf2 corrected emoji + linting 2024-08-29 22:47:33 +02:00
7552e55c8d removed diet filed 2024-08-29 22:19:11 +02:00
361de9f8b4 more bug fixing 2024-08-29 21:06:34 +02:00
e2426bd6a6 Bugfix 2024-08-29 20:03:43 +02:00
7fea619a9f add permission to make transfers with members of your club 2024-08-29 20:02:06 +02:00
7b5eefcc0a Update 2 files
- /apps/registration/views.py
- /apps/permission/fixtures/initial.json
2024-08-29 19:23:26 +02:00
e4aa16986f Merge branch 'survey_wei_2024' into 'main'
linting

See merge request bde/nk20!267
2024-08-29 19:12:23 +02:00
b92e6e4e10 linting 2024-08-29 18:36:20 +02:00
dd675b3676 Merge branch 'survey_wei_2024' into 'main'
Survey wei 2024

See merge request bde/nk20!266
2024-08-29 14:45:28 +02:00
f50849b4f8 delete print 2024-08-29 14:01:55 +02:00
73ff35c232 updated bus descr 2024-08-29 12:42:26 +02:00
a5df98224f Merge branch 'migration-django-4-2' into 'main'
Migration django 4 2

See merge request bde/nk20!265
2024-08-29 10:49:44 +02:00
2cb9ac8735 replace "…" -> "..." (#130) and disable sorting on certain columns (#129) 2024-08-29 10:19:06 +02:00
35d4849a28 fix Oauth 2024-08-29 00:43:33 +02:00
96539d262f working html for survey + fixed json error + added specific diet text field 2024-08-29 00:05:44 +02:00
946674f59b inclusif, avoids python3.10 syntax 2024-08-28 11:11:32 +02:00
a201d8376a updated survey 2024-08-28 11:01:33 +02:00
a21b9275ea Add caution_check in the validation form, #96 2024-08-28 09:48:52 +02:00
d4e85e8215 test wei 2024, linters 2024-08-28 09:48:52 +02:00
7af2ebba40 basic survey 2024-08-28 09:48:52 +02:00
bd94400883 Merge branch 'food_traceability' into 'main'
Change deprecated function

See merge request bde/nk20!264
2024-08-27 19:15:52 +02:00
5558341c8c Change deprecated function 2024-08-27 19:14:59 +02:00
35ef82223c Merge branch 'food_traceability' into 'main'
Create traceability application

See merge request bde/nk20!263
2024-08-27 18:46:54 +02:00
9ccac36831 Copy constructor 2024-08-27 18:01:13 +02:00
2e71ce05a9 Merge branch 'main' into food_traceability 2024-08-27 17:11:32 +02:00
f2cb10b69f Fix problem in addingredientform, change filter for container in QrcodeForm 2024-08-27 15:12:15 +02:00
24c4edf2e3 Merge branch 'migration-django-4-2' into 'main'
nk20 v2.0.0 with django 4.2

See merge request bde/nk20!230
2024-08-27 13:43:58 +02:00
213e9a8b12 Fix problem in addingredientform, change filter for container in QrcodeForm 2024-08-27 10:47:44 +02:00
2c56178b15 Merge branch 'main' into migration-django-4-2 2024-08-25 16:14:59 +02:00
48a5b04579 Merge branch 'beta' into migration-django-4-2 2024-08-25 16:13:01 +02:00
2ab5c4082a Merge branch 'beta' into 'main'
revert sort tables to member views

See merge request bde/nk20!262
2024-08-25 15:17:36 +02:00
053225c6dc revert sort tables to member views 2024-08-25 15:13:02 +02:00
ac7b86651d Merge branch 'beta' into 'main'
api errors (fix #113), sortable tables, calendar (fix #95), opener (fix #117), colored linters, inclusif, bug july 31, 403 (fix #65)

Closes #65, #117, #95, and #113

See merge request bde/nk20!260
2024-08-25 14:45:08 +02:00
21f5a5d566 Merge branch 'invoice_template' into 'main'
Update invoice_sample.tex, remove link toward bde.ens-cachan

See merge request bde/nk20!261
2024-08-25 14:34:37 +02:00
ff9c78ed4e added opener in admin and fixed the guest view 2024-08-25 14:29:06 +02:00
1e121297d1 Update invoice_sample.tex, remove link toward bde.ens-cachan 2024-08-23 00:32:37 +02:00
549f56dc0b Translation 2024-08-17 11:58:33 +02:00
debeb33d46 Improve/modify form, view, template. Add permissions 2024-08-17 02:42:29 +02:00
6d7076b03e Edit forms, views, template to improve/modify view. Edit urls to remove some path. Few changes in models. 2024-08-14 01:32:55 +02:00
196df1e775 Remove initial.json (food) mandatory allergen are directly created in migration. Edit tables.py and views.py transformedfoodlist.html to improve/change the view. Edit base.html, urls.py to correct little mistakes. Edit initial.json (permission) to begin permission for food apps and create a new role (Respo Bouffe). 2024-08-13 02:07:32 +02:00
28117c8c61 Add developers, Opener comments 2024-08-10 11:50:27 +02:00
0d9891fbd8 Merge branch 'migration-django-4-2' of gitlab.crans.org:bde/nk20 into migration-django-4-2 2024-08-09 23:20:48 +02:00
4be4a18dd1 Merge branch 'sortable_tables' into 'beta'
Sortable tables

See merge request bde/nk20!257
2024-08-08 17:37:31 +02:00
27b00ba4f0 Merge branch 'beta' into sortable_tables 2024-08-08 17:27:44 +02:00
3fcbb4f310 Merge branch 'no-api-error' into 'beta'
fix #113

See merge request bde/nk20!253
2024-08-08 17:05:25 +02:00
d1c9a2a7f1 Merge branch 'beta' into no-api-error 2024-08-08 16:54:21 +02:00
a673fd6871 Merge branch 'ouvreureuse' into 'beta'
Ouvreureuse

See merge request bde/nk20!256
2024-08-08 16:41:06 +02:00
a324d3a892 Merge branch 'beta' into ouvreureuse 2024-08-08 16:28:22 +02:00
951ba74f8f Merge branch 'bug_31_july' into 'beta'
bug du jour 31 juillet (bissextile)

See merge request bde/nk20!254
2024-08-08 16:23:21 +02:00
abc4f14bd1 Merge branch '404_or_403' into 'beta'
fix #65 Returning 403 when you don't have enough permissions

See merge request bde/nk20!259
2024-08-07 21:54:54 +02:00
47138bafd4 Merge branch 'traduction_inclusive_fr' into 'beta'
De l'inclusif, partout

See merge request bde/nk20!258
2024-08-07 21:45:05 +02:00
a3920fcae3 Merge branch 'Fix_time_zone_calendar.ics' into 'beta'
Update views.py - Fix calendar.ics

See merge request bde/nk20!237
2024-08-07 21:26:32 +02:00
ae4213d087 Merge branch 'colored_linters' into 'beta'
Colored linters

See merge request bde/nk20!255
2024-08-07 21:25:22 +02:00
5a0fe7a6f0 Fr translation (treasury summary) 2024-08-07 20:01:22 +02:00
eda8460014 Inclusif, admin and migrations (treasury summary) 2024-08-07 19:24:23 +02:00
b2b1f03b46 Edit food HTML template for translation, translations. Now the mandatory allergens are automatically created 2024-08-06 15:20:22 +02:00
1c5ed2bd3f Edit base.html and few translations 2024-08-06 13:59:30 +02:00
a7e87ea639 API Food 2024-08-04 23:38:21 +02:00
cbf92651f0 Returning 403 when you don't have enough permissions 2024-08-04 21:58:57 +02:00
12c93ff9da bug du jour 31 juillet (bissextile) 2024-08-04 14:45:17 +02:00
354c79bb82 Inclusif manquant 2024-08-04 13:32:33 +02:00
1ea7b3dda1 documentation and modification of permissions 2024-08-02 15:21:34 +02:00
15c71ad31a treasury summary 2024-08-02 01:35:13 +02:00
35ffbfcf55 Colored linters 2024-08-01 17:29:24 +02:00
162371042c Creation of "Opener", Fix #117 2024-08-01 14:49:52 +02:00
581715d804 Fix #95 (calendar) 2024-07-31 23:18:41 +02:00
c7c6f0350f Looks unused 2024-07-31 22:19:16 +02:00
9d1024024b Each table can be sorted (with a few exceptions) 2024-07-30 21:42:45 +02:00
d595d908c6 Fix tests
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2024-07-30 16:34:20 +02:00
734f5b242d C'est pas moi
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2024-07-30 16:32:19 +02:00
b0c7d43a50 De l'inclusif, partout
Signed-off-by: Emmy D'ANELLO <ynerant@crans.org>
2024-07-30 16:28:47 +02:00
6f67d2c629 Documentation 2024-07-22 15:52:09 +02:00
4b97ab2e2a linters 2024-07-22 15:52:09 +02:00
dcfd0167e7 Security against the cycles 2024-07-22 15:52:09 +02:00
50a680eed2 Open table and shelf life 2024-07-22 15:52:09 +02:00
226a2a6357 Automatic allergens and expiry_date update 2024-07-22 15:52:09 +02:00
48462f2ffc Adding ingredients to a preparation 2024-07-22 15:52:09 +02:00
260513ae3b Migration fixes 2024-07-22 15:52:09 +02:00
210a3cc93c Implementing QRcode creation, modifying Allergen model and creating of few views 2024-07-22 15:52:09 +02:00
896095a44c Un peu de nettoyage, rajout de commentaires 2024-07-22 15:52:09 +02:00
3f997f94fa few changes in models, delete default label 2024-07-22 15:52:09 +02:00
0801ad64ae création de forms fonctionnel (form + views + url + html), few changes in models.py 2024-07-22 15:52:09 +02:00
64bd5ed546 création d'un form pour l'ajout d'aliments basiques 2024-07-22 15:52:09 +02:00
4c390dce17 nom app 2024-07-22 15:52:09 +02:00
adacc293f5 First forms 2024-07-22 15:52:09 +02:00
968fa64d37 Réagencement des tables et de leurs attributs 2024-07-22 15:52:09 +02:00
a481adbae4 création de l'interface admin temporaire 2024-07-22 15:52:09 +02:00
4de2e987ef Rajout de la pseudo-doc 2024-07-22 15:52:07 +02:00
9e6342c929 Création de l'apps et de la base de donnée 2024-07-22 15:50:09 +02:00
74de358953 Update README.md 2024-07-22 15:50:09 +02:00
7322d55789 Fix #113. Fix regex in views. 2024-07-19 20:00:33 +02:00
1a258dfe9e Parse input of search filters to prevent errors based on invalid regex, fixes #113
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2024-07-19 19:59:30 +02:00
b8f81048a5 Merge branch 'fix_ActivityList' into 'main'
Allow to order the 2 tables and to fix the bug of several activities

See merge request bde/nk20!252
2024-07-18 18:17:06 +02:00
af819f45a1 Merge branch 'remove_picture' into 'main'
Allow you to delete the profile picture

See merge request bde/nk20!250
2024-07-18 18:02:43 +02:00
076d065ffa Merge branch 'main' into 'remove_picture'
# Conflicts:
#   locale/fr/LC_MESSAGES/django.po
2024-07-18 17:52:22 +02:00
2da77d9c17 Merge branch 'fix_join_bda' into 'main'
Fix #126 (join_bda)

Closes #126

See merge request bde/nk20!251
2024-07-18 17:14:23 +02:00
01584d6330 Merge branch 'modif_perm' into 'main'
Modif perm

See merge request bde/nk20!249
2024-07-18 16:54:23 +02:00
4c0a5922c4 Allow to order the 2 tables and to fix the bug of several activities 2024-07-15 22:06:11 +02:00
f90b28fc7c Fix #126 (join_bda) 2024-07-15 14:30:46 +02:00
bbbdcc7247 linters 2024-07-13 18:03:19 +02:00
925e0f26f5 Allow you to delete the profile picture 2024-07-13 17:37:19 +02:00
feeb99041f Fix the Alias Search API 2024-07-13 12:41:59 +02:00
c912383f86 oups la virgule oublié 2024-06-24 22:36:22 +02:00
32830e43fd Modify permission for negative 2024-06-24 21:21:22 +02:00
11c6a6fa7a modifications permissions consommation pc kfet (Alcool) 2024-06-24 16:57:39 +02:00
201d6b114a Merge branch 'new_logo' into 'main'
New logo

See merge request bde/nk20!247
2024-06-03 22:00:03 +02:00
19e77df299 Merge branch 'main' into 'new_logo'
# Conflicts:
#   .gitlab-ci.yml
2024-06-03 21:59:44 +02:00
5fd6ec5668 Merge branch 'charte_info' into 'main'
Charte info

See merge request bde/nk20!248
2024-06-03 21:53:01 +02:00
10a01c5bc2 linters 2024-05-30 20:21:56 +02:00
989905ea64 Update .gitlab-ci.yml 2024-05-26 18:41:49 +02:00
0218d43a17 Update .gitlab-ci.yml 2024-05-26 16:00:26 +02:00
5d30b0e819 charte info 2024-05-26 15:46:50 +02:00
ec759dd3c0 error py37-django22 2024-05-23 22:38:09 +02:00
2eb965291d new_logo 2024-05-23 21:46:01 +02:00
7f182ee2ee Merge branch 'traduction_inclusive_fr' into 'main'
Réécriture en inclusif de l'ensemble des textes français de la note

See merge request bde/nk20!246
2024-03-30 13:24:06 +01:00
3132aa4c38 Prise en compte des commentaires de Korenstin 2024-03-30 12:44:51 +01:00
c7eb774859 Prise en compte des commentaires 2024-03-30 11:20:23 +01:00
32f8d285b3 Prise en compte des commentaires 2024-03-30 11:12:33 +01:00
050256ea13 Réécriture en inclusif de l'ensemble des textes français de la note 2024-03-29 17:59:43 +01:00
7afd15b1cc Merge branch 'invoice_modification' into 'main'
changement template facture

Closes #128

See merge request bde/nk20!243
2024-03-27 19:10:40 +01:00
258361f116 Update forms.py 2024-03-27 10:25:38 +01:00
a307530579 Merge branch 'change_date' into 'main'
change date

See merge request bde/nk20!245
2024-03-27 10:19:37 +01:00
5de930bf40 Update forms.py 2024-03-27 10:04:14 +01:00
f7ebe0e99b Update forms.py 2024-03-27 09:43:49 +01:00
73de6e2176 Update forms.py 2024-03-27 09:20:32 +01:00
201611b105 change date 2024-03-26 08:33:34 +01:00
40c239e9da Update models.py 2024-03-24 16:41:18 +01:00
2aaab2b454 Update test_treasury.py 2024-03-24 15:55:46 +01:00
fc088dec86 Update test_treasury.py 2024-03-24 15:20:46 +01:00
2d60f1fd7b Merge branch 'patch_sort' into 'main'
patch sort and optional description

See merge request bde/nk20!244
2024-03-23 21:07:03 +01:00
7b48b09329 patch sort and optional description 2024-03-23 14:32:31 +01:00
ffac940511 changement template facture 2024-03-22 18:22:08 +01:00
50f98fd5ad Merge branch 'prez-perm' into 'main'
changed permission for club president

See merge request bde/nk20!242
2024-03-22 12:56:09 +01:00
402e19d1ce changed permission for club president 2024-03-22 12:27:08 +01:00
0b0394b61f Merge branch 'image_fix' into 'main'
réparation photo de profil

See merge request bde/nk20!241
2024-03-21 20:57:56 +01:00
98422d8259 réparation photo de profil 2024-03-21 18:37:47 +01:00
29509b5b26 Merge branch 'quark-main-patch-96792' into 'main'
Changement couleur de la note

See merge request bde/nk20!240
2024-03-14 17:38:29 +01:00
0d64ad31e0 Update custom.css 2024-03-14 17:22:41 +01:00
5781cbd6a5 Merge branch 'quark-main-patch-83351' into 'main'
Changement couleur de la note

See merge request bde/nk20!239
2024-03-14 16:16:37 +01:00
5295e61a00 Changement couleur de la note 2024-03-14 15:59:53 +01:00
e79ed6226a Merge branch 'quark-main-patch-51348' into 'main'
Upload New Migration (change bde)

See merge request bde/nk20!238
2024-03-11 16:28:41 +01:00
68152e6354 Upload New Migration (change bde) 2024-03-11 16:11:54 +01:00
6c61daf1c5 Update views.py
Passage à la time zone Europe/Paris
2024-03-11 10:25:48 +01:00
b8cc297baf Merge branch 'quark-main-patch-c661' into 'main'
Update facture template

See merge request bde/nk20!236
2024-03-09 16:25:45 +01:00
cd8224f2e0 Upload New File 2024-03-09 16:06:39 +01:00
3c882a7854 Delete RavePartlist_bg.png 2024-03-09 16:06:01 +01:00
357e1bbaa2 Replace RavePartlist_bg.png 2024-03-09 16:05:29 +01:00
f5c4c58525 Replace RavePartlist_bg.png 2024-03-09 14:03:17 +01:00
dafb602b08 Update models.py 2024-03-09 13:40:45 +01:00
5b377e6a75 Update facture template 2024-03-09 13:04:33 +01:00
28bd62531e Merge branch 'docs-append' into 'main'
Add : Documentation years flag for Extract ML Registrations

See merge request bde/nk20!235
2024-03-08 19:51:12 +01:00
b3a31c27a5 Add : Documentation years flag for Extract ML Registrations 2024-03-08 19:34:48 +01:00
c7a8e6a1a5 Merge branch 'fin_de_campagne' into 'main'
Remove BDE compaign banner

See merge request bde/nk20!234
2024-02-16 16:58:42 +01:00
546a3a72b1 Remove BDE compaign banner 2024-02-15 10:32:39 +01:00
2e5664f79d Merge branch 'Compromis' into 'main'
Update base.html compromis

See merge request bde/nk20!233
2024-02-13 23:27:32 +01:00
e367666fe9 Update base.html compromis 2024-02-13 23:27:11 +01:00
04a9b3daf0 Merge branch 'Revanche' into 'main'
Update base.html

See merge request bde/nk20!232
2024-02-13 21:25:16 +01:00
d1df8f3eac Update base.html
📢Pour la meilleure liste BDE
2024-02-13 21:24:23 +01:00
a5221f66ef Merge branch 'main' into 'main'
Compaign banner

See merge request bde/nk20!231
2024-02-13 14:58:31 +01:00
7d59cd6cd2 Compaign banner 2024-02-13 14:26:28 +01:00
96215cc1ff oidc_claim_scope in Class instead of method 2024-02-13 13:43:14 +01:00
b7a71d911d _get_validtion_exclusions() now return a set, PIL.Image.ANTIALIAS was renamed LANCZOS and typo in .gitlab-ci.yml 2024-02-12 22:56:43 +01:00
2ee7f41dfe tests with ubuntu 22.04, django-bootstrap-datepicker-plus is a standalone package and fix encoding in tests 2024-02-12 21:25:07 +01:00
fb3337966e bootstrap4 is now a standalone package from crispy-forms 2024-02-11 22:24:37 +01:00
0db0474217 Merge branch 'Update_2024_Copyright' into 'main'
Update 131 files

See merge request bde/nk20!229
2024-02-11 17:29:46 +01:00
2b3eb15f59 fix one copyright and a string before merge 2024-02-11 16:58:53 +01:00
399a32bece default auto field 2024-02-11 16:51:48 +01:00
82fea65b5e django_htcpcp_tea in middleware only if in apps 2024-02-07 20:03:57 +01:00
abc88d0118 replace url from django.conf.urls by re_path from django.urls 2024-02-07 18:21:08 +01:00
b6b81a8b8f typo 2024-02-07 18:05:32 +01:00
d228dbf225 fix some breaking changes and linters 2024-02-07 18:02:56 +01:00
a6b479db19 Update 131 files
- /apps/activity/api/serializers.py
- /apps/activity/api/urls.py
- /apps/activity/api/views.py
- /apps/activity/tests/test_activities.py
- /apps/activity/__init__.py
- /apps/activity/admin.py
- /apps/activity/apps.py
- /apps/activity/forms.py
- /apps/activity/tables.py
- /apps/activity/urls.py
- /apps/activity/views.py
- /apps/api/__init__.py
- /apps/api/apps.py
- /apps/api/serializers.py
- /apps/api/tests.py
- /apps/api/urls.py
- /apps/api/views.py
- /apps/api/viewsets.py
- /apps/logs/signals.py
- /apps/logs/apps.py
- /apps/logs/__init__.py
- /apps/logs/api/serializers.py
- /apps/logs/api/urls.py
- /apps/logs/api/views.py
- /apps/member/api/serializers.py
- /apps/member/api/urls.py
- /apps/member/api/views.py
- /apps/member/templatetags/memberinfo.py
- /apps/member/__init__.py
- /apps/member/admin.py
- /apps/member/apps.py
- /apps/member/auth.py
- /apps/member/forms.py
- /apps/member/hashers.py
- /apps/member/signals.py
- /apps/member/tables.py
- /apps/member/urls.py
- /apps/member/views.py
- /apps/note/api/serializers.py
- /apps/note/api/urls.py
- /apps/note/api/views.py
- /apps/note/models/__init__.py
- /apps/note/static/note/js/consos.js
- /apps/note/templates/note/mails/negative_balance.txt
- /apps/note/templatetags/getenv.py
- /apps/note/templatetags/pretty_money.py
- /apps/note/tests/test_transactions.py
- /apps/note/__init__.py
- /apps/note/admin.py
- /apps/note/apps.py
- /apps/note/forms.py
- /apps/note/signals.py
- /apps/note/tables.py
- /apps/note/urls.py
- /apps/note/views.py
- /apps/permission/api/serializers.py
- /apps/permission/api/urls.py
- /apps/permission/api/views.py
- /apps/permission/templatetags/perms.py
- /apps/permission/tests/test_oauth2.py
- /apps/permission/tests/test_permission_denied.py
- /apps/permission/tests/test_permission_queries.py
- /apps/permission/tests/test_rights_page.py
- /apps/permission/__init__.py
- /apps/permission/admin.py
- /apps/permission/backends.py
- /apps/permission/apps.py
- /apps/permission/decorators.py
- /apps/permission/permissions.py
- /apps/permission/scopes.py
- /apps/permission/signals.py
- /apps/permission/tables.py
- /apps/permission/urls.py
- /apps/permission/views.py
- /apps/registration/tests/test_registration.py
- /apps/registration/__init__.py
- /apps/registration/apps.py
- /apps/registration/forms.py
- /apps/registration/tables.py
- /apps/registration/tokens.py
- /apps/registration/urls.py
- /apps/registration/views.py
- /apps/treasury/api/serializers.py
- /apps/treasury/api/urls.py
- /apps/treasury/api/views.py
- /apps/treasury/templatetags/escape_tex.py
- /apps/treasury/tests/test_treasury.py
- /apps/treasury/__init__.py
- /apps/treasury/admin.py
- /apps/treasury/apps.py
- /apps/treasury/forms.py
- /apps/treasury/signals.py
- /apps/treasury/tables.py
- /apps/treasury/urls.py
- /apps/treasury/views.py
- /apps/wei/api/serializers.py
- /apps/wei/api/urls.py
- /apps/wei/api/views.py
- /apps/wei/forms/surveys/__init__.py
- /apps/wei/forms/surveys/base.py
- /apps/wei/forms/surveys/wei2021.py
- /apps/wei/forms/surveys/wei2022.py
- /apps/wei/forms/surveys/wei2023.py
- /apps/wei/forms/__init__.py
- /apps/wei/forms/registration.py
- /apps/wei/management/commands/export_wei_registrations.py
- /apps/wei/management/commands/import_scores.py
- /apps/wei/management/commands/wei_algorithm.py
- /apps/wei/templates/wei/weilist_sample.tex
- /apps/wei/tests/test_wei_algorithm_2021.py
- /apps/wei/tests/test_wei_algorithm_2022.py
- /apps/wei/tests/test_wei_algorithm_2023.py
- /apps/wei/tests/test_wei_registration.py
- /apps/wei/__init__.py
- /apps/wei/admin.py
- /apps/wei/apps.py
- /apps/wei/tables.py
- /apps/wei/urls.py
- /apps/wei/views.py
- /note_kfet/settings/__init__.py
- /note_kfet/settings/base.py
- /note_kfet/settings/development.py
- /note_kfet/settings/secrets_example.py
- /note_kfet/static/js/base.js
- /note_kfet/admin.py
- /note_kfet/inputs.py
- /note_kfet/middlewares.py
- /note_kfet/urls.py
- /note_kfet/views.py
- /note_kfet/wsgi.py
- /entrypoint.sh
2024-02-07 02:26:49 +01:00
048d251f75 Merge branch 'charliep-main-patch-40779' into 'main'
update Copyright 2024

See merge request bde/nk20!228
2024-02-07 02:05:59 +01:00
7b11cb0797 update Copyright 2024 2024-02-07 01:37:43 +01:00
516a7f4be5 Remove importation of django-htcpcp-tea which is not compatible with django 4.2 2024-01-24 20:14:32 +01:00
2f8c9b54e7 Remove importation of django-cas-server which is not compatible with django 4.2 2024-01-24 19:58:55 +01:00
e9f18c3ed9 migrate to django 4.2 (LTS), change requirement and tests. remove depreciated ifnotequal 2024-01-24 19:18:02 +01:00
ff3c30517e Merge branch 'happy-new-year' into 'main'
happy new year

See merge request bde/nk20!226
2024-01-11 16:48:06 +01:00
f481ea6acb happy new year (contain annually WEI change and update to follow Django Style Guide) 2024-01-11 16:32:37 +01:00
802fd8c2d7 Merge branch 'search_conso_bugfix' into 'main'
Bugfix

See merge request bde/nk20!225
2023-11-13 14:29:29 +01:00
5209a586a9 Fixed const being redeclared when script is reevaluated 2023-11-08 17:10:05 +01:00
24f54ac876 Merge branch 'search-conso' into 'main'
Added a search tab for the conso page, fixes #58

Closes #58

See merge request bde/nk20!224
2023-10-27 16:45:41 +02:00
988b4c9e88 Linting 2023-10-26 21:03:48 +02:00
e32c267995 Moved js code to the external conso file 2023-10-26 19:10:43 +02:00
5e39209ab1 Made searchbar completely client-based 2023-10-26 19:01:09 +02:00
08b2fabe07 Removing jquery means changing the event API... 2023-10-26 00:22:51 +02:00
405479e5ad Execute script to add behavior to searched buttons 2023-10-26 00:10:56 +02:00
0cc130092f Added a search tab for the conso page 2023-10-25 20:01:48 +02:00
ff6e207512 Merge branch 'beta' into 'main'
check for a model in permission and use that in treasury

See merge request bde/nk20!222
2023-09-29 12:08:00 +02:00
0f1e4d2e60 check for a model in permission and use that in treasury 2023-09-28 18:48:57 +02:00
6255bcbbb1 Merge branch 'beta' into 'main'
Merge beta

See merge request bde/nk20!221
2023-09-27 17:14:49 +02:00
d82a1001c4 Moved transaction through frienships right to basic rights 2023-09-27 16:55:00 +02:00
31a54482f0 Updated doc to tell maintainers to create psql superusers 2023-09-27 16:53:30 +02:00
4ee02345d4 Merge branch 'better-friendship-view' into 'main'
Rework of the friendships page

See merge request bde/nk20!220
2023-09-21 15:48:00 +02:00
422c087d17 fix wei test 2023-09-20 07:04:13 +02:00
30d6e2c95e Added trusts to note admin site 2023-09-19 15:07:30 +02:00
f3a3f07e38 Tweaked message and did missing french translations 2023-09-18 17:29:52 +02:00
a5e802f370 Improved the error message when trying to duplicate a Trust 2023-09-18 17:12:31 +02:00
540f3bc354 regenerated messages so locations are consistent with codebase 2023-09-02 00:04:54 +02:00
2d19457506 Add spanish translation for friendship 2023-09-01 17:35:52 +02:00
72786d0d2b Translated js strings, unified some case 2023-09-01 17:34:52 +02:00
f099cbc879 Linting 2023-09-01 17:32:29 +02:00
977eb7c0d4 Generated translation files, did french 2023-09-01 17:30:38 +02:00
d81b1f2710 Tweaked trust back display 2023-09-01 17:15:24 +02:00
6a69590a82 Added a 'trust back' button, front can be improved 2023-09-01 17:15:24 +02:00
7afc583282 Made trust adding widget resetable, corrected the unexpected empty field behavior and improved autocomplete's responsiveness 2023-09-01 17:15:24 +02:00
4fb0b7d736 First pass on a display of users trusting you, added a corresponding right 2023-09-01 17:15:13 +02:00
18a5b65a1c Merge branch 'VSS' into 'main'
anti VSS

See merge request bde/nk20!219
2023-08-31 15:58:52 +02:00
f545af4977 typo 2023-08-31 15:40:49 +02:00
103e2d0635 add GC anti-VSS 2023-08-31 15:25:44 +02:00
aedf0e87ba prez BDE can block note 2023-08-31 13:46:27 +02:00
dab45b5fd4 translation 2023-08-31 13:40:53 +02:00
b3353b563c add VSS checkbox on registration 2023-08-31 12:21:38 +02:00
6bc52be707 Merge branch 'WEI_with_questions' into 'main'
Wei with questions

See merge request bde/nk20!218
2023-08-31 12:01:39 +02:00
834d68fe35 typo 2023-08-31 11:45:17 +02:00
c6a2849d35 test 2023-08-30 16:16:29 +02:00
4ab22c92b3 After WEI registration validation, come back to unvalidate registration page 2023-08-30 09:52:17 +02:00
c328c1457c add register button at the end of WEI registration 2023-08-28 22:27:45 +02:00
96da7d01ae change on a field that everyone have (1A don't have bus) 2023-08-28 19:26:51 +02:00
d27f942339 typo 2023-08-28 10:13:28 +02:00
738d6c932d questions ! 2023-08-28 00:42:33 +02:00
1760196578 more tests 2023-08-27 23:11:40 +02:00
13b9b6edea tests 2023-08-27 18:09:46 +02:00
e06e3b2972 one question by page 2023-08-26 23:47:10 +02:00
9596aa7b8c base for questions instead of words 2023-08-26 17:52:48 +02:00
ba0d64f0d4 Merge branch 'new_default_year' into 'main'
new default year

See merge request bde/nk20!217
2023-08-23 23:53:45 +02:00
8d17801e28 new default year 2023-08-23 23:32:01 +02:00
609362c4f8 Merge branch 'update_permission' into 'main'
Update permission

See merge request bde/nk20!216
2023-08-23 22:50:24 +02:00
03d2d5f03e change -50€ to -20€ and doc 2023-08-22 21:51:02 +02:00
d2057a9f45 remove respo-info perm and change Prez BDE prem 2023-08-22 21:19:05 +02:00
b6e68eeebe Merge branch 'charliep-main-patch-47507' into 'main'
Update forms.py - Homogénéisation des cases

See merge request bde/nk20!215
2023-08-08 15:39:44 +02:00
6410542027 Update forms.py - Homogénéisation des cases 2023-08-08 15:38:29 +02:00
6b1cd3ba7a manage self aliases for BDE member instead of kfet 2023-07-24 12:42:44 +02:00
9f114b8ca2 fixtures activities 2023-07-24 12:26:34 +02:00
e0132b6dc8 migration permission 2023-07-24 12:20:16 +02:00
f1cc82fab3 Merge branch 'linters' into 'main'
Linters

See merge request bde/nk20!214
2023-07-17 09:27:22 +02:00
644cf14c4b missing brackets 2023-07-17 09:11:25 +02:00
f19a489313 linters (removing B019) 2023-07-17 08:50:10 +02:00
dedd6c69cc new commits in nk20-scripts 2023-07-17 06:58:01 +02:00
b42f5afeab Merge branch 'registration2023' into 'main'
Registration2023

See merge request bde/nk20!213
2023-07-16 17:12:33 +02:00
31e67ae3f6 typo 2023-07-09 16:06:30 +02:00
b08da7a727 help text on WEI emergency contact 2023-07-09 14:57:48 +02:00
451aa64f33 Unisexe clothing cut 2023-07-09 12:30:23 +02:00
3c99b0f3e9 do not change transactions date when validating/deleting credit-soge (and typo) 2023-07-09 11:23:33 +02:00
201a179947 linters 2023-07-09 10:36:36 +02:00
96784aee3b remove (comment) soge from registration 2023-07-07 21:44:18 +02:00
981c4d0300 fix update of club membership start/end date 2023-07-07 20:39:19 +02:00
11223430fd Merge branch 'WEI2023' into 'main'
Préparation WEI 2023

See merge request bde/nk20!212
2023-07-04 19:17:17 +02:00
7aeb977e72 Oubli dans le fichier test_wei_registration_.py d'un 2022 en 2023 2023-07-04 18:33:54 +02:00
52fef1df42 Préparation WEI 2023 2023-07-04 18:23:43 +02:00
16f8a60a3f possibilité de l'adhésion au BDA lors de l'inscription 2023-07-04 17:32:48 +02:00
2839d3de1e club facultatif pour un role lors du changement dans l'interface admin 2023-06-22 14:52:11 +02:00
30afa6da0a création d'une permission pour faire les crédits uniquement 2023-06-12 18:29:23 +02:00
84fc77696f see activities: BDE members instead of kfet 2023-06-05 19:04:19 +02:00
19fc620d1f see kfet members' note for respot 2023-06-05 17:26:49 +02:00
d5819ac562 Merge branch 'FAQ' into 'main'
Ajout d'un lien vers la FAQ de la note.

See merge request bde/nk20!209
2023-04-18 15:51:38 +02:00
a79df8f1f6 Merge branch 'invoice_bg_storlist' into 'main'
changement du fond des factures

See merge request bde/nk20!211
2023-04-14 19:29:26 +02:00
364b18e188 migrations 2023-04-14 16:52:46 +02:00
10a883b2e5 new treasury phone number 2023-04-14 16:00:48 +02:00
1410ab6c4f Almost on time, the SIRET number is now changed 2023-04-14 15:35:18 +02:00
623dd61be6 Remove phone number 2023-04-14 14:56:34 +02:00
48a0a87e7c changement du fond des factures 2023-04-14 00:25:26 +02:00
563f525b11 Merge branch 'cron' into 'main'
fréquence des mails de négatif aux trez : 1 mois -> 1 semaine, et les notes liées au BDE n'apparaissent plus

See merge request bde/nk20!210
2023-04-08 13:04:59 +02:00
63c1d74f1a Ignore notes containing '- BDE-' in the list of negative balances 2023-04-07 15:47:06 +02:00
c42fb380a6 frequence des mails de négatif aux trez : 1 mois -> 1 semiane 2023-04-06 09:04:27 +02:00
c636d52a73 traduction (allemand et espagnol probablement pas optimal) 2023-03-31 17:21:58 +02:00
9c9149b53a Ajout d'un lien vers la FAQ de la note. 2023-03-31 12:34:14 +02:00
6bceb394c5 prez BDE sould see invoice list 2023-03-29 20:43:54 +02:00
62cf8f9d84 forgetted coma 2023-03-28 20:41:53 +02:00
2dd1c3fb89 change mask for some perm 2023-03-20 22:35:51 +01:00
c8665c5798 change permissions for role 2023-03-20 22:21:18 +01:00
e9f1b6f52d change permanent permissions 2023-03-20 17:19:14 +01:00
1d95ae4810 sort perm by number 2023-03-20 16:16:32 +01:00
293 changed files with 16256 additions and 7072 deletions

View File

@ -7,25 +7,10 @@ stages:
variables:
GIT_SUBMODULE_STRATEGY: recursive
# Debian Buster
py37-django22:
# Ubuntu 22.04
py310-django42:
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
# Ubuntu 20.04
py38-django22:
stage: test
image: ubuntu:20.04
image: ubuntu:22.04
before_script:
# Fix tzdata prompt
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
@ -37,12 +22,12 @@ py38-django22:
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 py310-django42
# Debian Bullseye
py39-django22:
# Debian Bookworm
py311-django42:
stage: test
image: debian:bullseye
image: debian:bookworm
before_script:
- >
apt-get update &&
@ -52,11 +37,11 @@ py39-django22:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py39-django22
script: tox -e py311-django42
linters:
stage: quality-assurance
image: debian:buster-backports
image: debian:bookworm
before_script:
- apt-get update && apt-get install -y tox
script: tox -e linters

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "apps/scripts"]
path = apps/scripts
url = https://gitlab.crans.org/bde/nk20-scripts.git
url = https://gitlab.crans.org/bde/nk20-scripts

View File

@ -55,7 +55,7 @@ Bien que cela permette de créer une instance sur toutes les distributions,
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
(env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
(env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
```
6. Enjoy :

View File

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

View File

@ -1,11 +1,11 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from note_kfet.admin import admin_site
from .forms import GuestForm
from .models import Activity, ActivityType, Entry, Guest
from .models import Activity, ActivityType, Entry, Guest, Opener
@admin.register(Activity, site=admin_site)
@ -45,3 +45,11 @@ class EntryAdmin(admin.ModelAdmin):
Admin customisation for Entry
"""
list_display = ('note', 'activity', 'time', 'guest')
@admin.register(Opener, site=admin_site)
class OpenerAdmin(admin.ModelAdmin):
"""
Admin customisation for Opener
"""
list_display = ('activity', 'opener')

View File

@ -1,9 +1,11 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
class ActivityTypeSerializer(serializers.ModelSerializer):
@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
class Meta:
model = GuestTransaction
fields = '__all__'
class OpenerSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Openers.
The djangorestframework plugin will analyse the model `Opener` and parse all fields in the API.
"""
class Meta:
model = Opener
fields = '__all__'
validators = [UniqueTogetherValidator(
queryset=Opener.objects.all(), fields=("opener", "activity"),
message=_("This opener already exists"))]

View File

@ -1,7 +1,7 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet, OpenerViewSet
def register_activity_urls(router, path):
@ -12,3 +12,4 @@ def register_activity_urls(router, path):
router.register(path + '/type', ActivityTypeViewSet)
router.register(path + '/guest', GuestViewSet)
router.register(path + '/entry', EntryViewSet)
router.register(path + '/opener', OpenerViewSet)

View File

@ -1,12 +1,15 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet
from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.response import Response
from rest_framework import status
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer
from ..models import Activity, ActivityType, Entry, Guest
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
from ..models import Activity, ActivityType, Entry, Guest, Opener
class ActivityTypeViewSet(ReadProtectedModelViewSet):
@ -29,7 +32,7 @@ class ActivityViewSet(ReadProtectedModelViewSet):
"""
queryset = Activity.objects.order_by('id')
serializer_class = ActivitySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
'date_start', 'date_end', 'valid', 'open', ]
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
@ -47,7 +50,7 @@ class GuestViewSet(ReadProtectedModelViewSet):
"""
queryset = Guest.objects.order_by('id')
serializer_class = GuestSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
'inviter__alias__normalized_name', ]
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
@ -62,7 +65,36 @@ class EntryViewSet(ReadProtectedModelViewSet):
"""
queryset = Entry.objects.order_by('id')
serializer_class = EntrySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'time', 'note', 'guest', ]
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
'$guest__last_name', '$guest__first_name', ]
class OpenerViewSet(ReadProtectedModelViewSet):
"""
REST Opener View set.
The djangorestframework plugin will get all `Opener` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/opener/
"""
queryset = Opener.objects
serializer_class = OpenerSerializer
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend]
search_fields = ['$opener__alias__name', '$opener__alias__normalized_name',
'$activity__name']
filterset_fields = ['opener', 'opener__noteuser__user', 'activity']
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']:
# opener-activity can't change
serializer_class.Meta.read_only_fields = ('opener', 'acitivity',)
return serializer_class
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
try:
self.perform_destroy(instance)
except ValidationError as e:
return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

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

View File

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

View File

@ -1,16 +1,17 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note.models import Note, NoteUser
from note_kfet.inputs import Autocomplete, DateTimePickerInput
from note_kfet.inputs import Autocomplete
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
@ -43,7 +44,7 @@ class ActivityForm(forms.ModelForm):
class Meta:
model = Activity
exclude = ('creater', 'valid', 'open', )
exclude = ('creater', 'valid', 'open', 'opener', )
widgets = {
"organizer": Autocomplete(
model=Club,

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2024-03-23 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('activity', '0002_auto_20200904_2341'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='description',
field=models.TextField(blank=True, default='', verbose_name='description'),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 2.2.28 on 2024-08-01 12:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0006_trust'),
('activity', '0003_auto_20240323_1422'),
]
operations = [
migrations.CreateModel(
name='Opener',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opener', to='activity.Activity', verbose_name='activity')),
('opener', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.Note', verbose_name='opener')),
],
options={
'verbose_name': 'opener',
'verbose_name_plural': 'openers',
'unique_together': {('opener', 'activity')},
},
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0006_trust'),
('activity', '0004_opener'),
]
operations = [
migrations.AlterModelOptions(
name='opener',
options={'verbose_name': 'Opener', 'verbose_name_plural': 'Openers'},
),
migrations.AlterField(
model_name='opener',
name='opener',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.note', verbose_name='Opener'),
),
]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
@ -11,7 +11,7 @@ from django.db import models, transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteUser, Transaction
from note.models import NoteUser, Transaction, Note
from rest_framework.exceptions import ValidationError
@ -66,6 +66,8 @@ class Activity(models.Model):
description = models.TextField(
verbose_name=_('description'),
blank=True,
default="",
)
location = models.CharField(
@ -123,6 +125,14 @@ class Activity(models.Model):
verbose_name=_('open'),
)
class Meta:
verbose_name = _("activity")
verbose_name_plural = _("activities")
unique_together = ("name", "date_start", "date_end",)
def __str__(self):
return self.name
@transaction.atomic
def save(self, *args, **kwargs):
"""
@ -144,14 +154,6 @@ class Activity(models.Model):
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities()
return ret
def __str__(self):
return self.name
class Meta:
verbose_name = _("activity")
verbose_name_plural = _("activities")
unique_together = ("name", "date_start", "date_end",)
class Entry(models.Model):
"""
@ -252,14 +254,13 @@ class Guest(models.Model):
verbose_name=_("inviter"),
)
@property
def has_entry(self):
try:
if self.entry:
return True
return False
except AttributeError:
return False
class Meta:
verbose_name = _("guest")
verbose_name_plural = _("guests")
unique_together = ("activity", "last_name", "first_name", )
def __str__(self):
return self.first_name + " " + self.last_name
@transaction.atomic
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
@ -290,13 +291,14 @@ class Guest(models.Model):
return super().save(force_insert, force_update, using, update_fields)
def __str__(self):
return self.first_name + " " + self.last_name
class Meta:
verbose_name = _("guest")
verbose_name_plural = _("guests")
unique_together = ("activity", "last_name", "first_name", )
@property
def has_entry(self):
try:
if self.entry:
return True
return False
except AttributeError:
return False
class GuestTransaction(Transaction):
@ -308,3 +310,31 @@ class GuestTransaction(Transaction):
@property
def type(self):
return _('Invitation')
class Opener(models.Model):
"""
Allow the user to make activity entries without more rights
"""
activity = models.ForeignKey(
Activity,
on_delete=models.CASCADE,
related_name='opener',
verbose_name=_('activity')
)
opener = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='activity_responsible',
verbose_name=_('Opener')
)
class Meta:
verbose_name = _("Opener")
verbose_name_plural = _("Openers")
unique_together = ("opener", "activity")
def __str__(self):
return _("{opener} is opener of activity {acivity}").format(
opener=str(self.opener), acivity=str(self.activity))

View File

@ -0,0 +1,57 @@
/**
* On form submit, add a new opener
*/
function form_create_opener (e) {
// Do not submit HTML form
e.preventDefault()
// Get data and send to API
const formData = new FormData(e.target)
$.getJSON('/api/note/alias/'+formData.get('opener') + '/',
function (opener_alias) {
create_opener(formData.get('activity'), opener_alias.note)
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* Add an opener between an activity and a user
* @param activity:Integer activity id
* @param opener:Integer user note id
*/
function create_opener(activity, opener) {
$.post('/api/activity/opener/', {
activity: activity,
opener: opener,
csrfmiddlewaretoken: CSRF_TOKEN
}).done(function () {
// Reload tables
$('#opener_table').load(location.pathname + ' #opener_table')
addMsg(gettext('Opener successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the opener
* @param button_id:Integer Opener id to remove
*/
function delete_button (button_id) {
$.ajax({
url: '/api/activity/opener/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
addMsg(gettext('Opener successfully deleted'), 'success')
$('#opener_table').load(location.pathname + ' #opener_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
$(document).ready(function () {
// Attach event
document.getElementById('form_opener').addEventListener('submit', form_create_opener)
})

View File

@ -1,15 +1,17 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils import timezone
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
import django_tables2 as tables
from django_tables2 import A
from permission.backends import PermissionBackend
from note.templatetags.pretty_money import pretty_money
from .models import Activity, Entry, Guest
from .models import Activity, Entry, Guest, Opener
class ActivityTable(tables.Table):
@ -113,3 +115,34 @@ class EntryTable(tables.Table):
'data-last-name': lambda record: record.last_name,
'data-first-name': lambda record: record.first_name,
}
# function delete_button(id) provided in template file
DELETE_TEMPLATE = """
<button id="{{ record.pk }}" class="btn btn-danger btn-sm" onclick="delete_button(this.id)"> {{ delete_trans }}</button>
"""
class OpenerTable(tables.Table):
class Meta:
attrs = {
'class': 'table table condensed table-striped',
'id': "opener_table"
}
model = Opener
fields = ("opener",)
template_name = 'django_tables2/bootstrap4.html'
show_header = False
opener = tables.Column(attrs={'td': {'class': 'text-center'}})
delete_col = tables.TemplateColumn(
template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('Delete')},
attrs={
'td': {
'class': lambda record: 'col-sm-1'
+ (' d-none' if not PermissionBackend.check_perm(
get_current_request(), "activity.delete_opener", record)
else '')}},
verbose_name=_("Delete"),)

View File

@ -4,11 +4,31 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{% load render_table from django_tables2 %}
{% load static django_tables2 i18n %}
{% block content %}
<h1 class="text-white">{{ title }}</h1>
{% include "activity/includes/activity_info.html" %}
{% if activity.activity_type.manage_entries and ".change__opener"|has_perm:activity %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{% trans "Openers" %}
</h3>
<div class="card-body">
<form class="input-group" method="POST" id="form_opener">
{% csrf_token %}
<input type="hidden" name="activity" value="{{ object.pk }}">
{%include "autocomplete_model.html" %}
<div class="input-group-append">
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
</div>
</form>
</div>
{% render_table opener %}
</div>
{% endif %}
{% if guests.data %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
@ -22,6 +42,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %}
{% block extrajavascript %}
<script src="{% static "activity/js/opener.js" %}"></script>
<script src="{% static "js/autocomplete_model.js" %}"></script>
<script>
function remove_guest(guest_id) {
$.ajax({

View File

@ -17,4 +17,27 @@ SPDX-License-Identifier: GPL-3.0-or-later
</form>
</div>
</div>
{% endblock %}
{% endblock %}
{% block extrajavascript %}
<script>
var date_end = document.getElementById("id_date_end");
var date_start = document.getElementById("id_date_start");
function update_date_end (){
if(date_end.value=="" || date_end.value<date_start.value){
date_end.value = date_start.value;
};
};
function update_date_start (){
if(date_start.value=="" || date_end.value<date_start.value){
date_start.value = date_end.value;
};
};
date_start.addEventListener('focusout', update_date_end);
date_end.addEventListener('focusout', update_date_start);
</script>
{% endblock %}

View File

@ -46,4 +46,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h3>
{% render_table table %}
</div>
{% endblock %}
{% endblock %}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from hashlib import md5
@ -17,14 +17,16 @@ 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.views.generic.list import ListView
from django_tables2.views import MultiTableMixin, SingleTableMixin
from api.viewsets import is_regex
from note.models import Alias, NoteSpecial, NoteUser
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ActivityForm, GuestForm
from .models import Activity, Entry, Guest
from .tables import ActivityTable, EntryTable, GuestTable
from .models import Activity, Entry, Guest, Opener
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
@ -57,26 +59,36 @@ class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk})
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
Displays all Activities, and classify if they are on-going or upcoming ones.
"""
model = Activity
table_class = ActivityTable
ordering = ('-date_start',)
tables = [
lambda data: ActivityTable(data, prefix="all-"),
lambda data: ActivityTable(data, prefix="upcoming-"),
]
extra_context = {"title": _("Activities")}
def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct()
def get_tables_data(self):
# first table = all activities, second table = upcoming
return [
self.get_queryset().order_by("-date_start"),
Activity.objects.filter(date_end__gt=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
.distinct()
.order_by("date_start")
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
context['upcoming'] = ActivityTable(
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")),
prefix='upcoming-',
)
tables = context["tables"]
for name, table in zip(["table", "upcoming"], tables):
context[name] = table
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
context["started_activities"] = started_activities
@ -84,7 +96,7 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
return context
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
"""
Shows details about one activity. Add guest to context
"""
@ -92,15 +104,40 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = "activity"
extra_context = {"title": _("Activity detail")}
tables = [
lambda data: GuestTable(data, prefix="guests-"),
lambda data: OpenerTable(data, prefix="opener-"),
]
def get_tables_data(self):
return [
Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
self.object.opener.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
]
def get_context_data(self, **kwargs):
context = super().get_context_data()
table = GuestTable(data=Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
context["guests"] = table
tables = context["tables"]
for name, table in zip(["guests", "opener"], tables):
context[name] = table
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
context["widget"] = {
"name": "opener",
"resetable": True,
"attrs": {
"class": "autocomplete form-control",
"id": "opener",
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
"name_field": "name",
"placeholder": ""
}
}
return context
@ -157,12 +194,14 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
class ActivityEntryView(LoginRequiredMixin, TemplateView):
class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
"""
Manages entry to an activity
"""
template_name = "activity/activity_entry.html"
table_class = EntryTable
def dispatch(self, request, *args, **kwargs):
"""
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
@ -197,13 +236,16 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
if pattern[0] != "^":
pattern = "^" + pattern
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix = "__iregex" if valid_regex else "__istartswith"
pattern = "^" + pattern if valid_regex and pattern[0] != "^" else 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))
Q(**{f"first_name{suffix}": pattern})
| Q(**{f"last_name{suffix}": pattern})
| Q(**{f"inviter__alias__name{suffix}": pattern})
| Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)})
)
else:
guest_qs = guest_qs.none()
@ -223,23 +265,26 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
# Keep only users that have a note
note_qs = note_qs.filter(note__noteuser__isnull=False)
# Keep only members
# Keep only valid members
note_qs = note_qs.filter(
note__noteuser__user__memberships__club=activity.attendees_club,
note__noteuser__user__memberships__date_start__lte=timezone.now(),
note__noteuser__user__memberships__date_end__gte=timezone.now(),
)
note__noteuser__user__memberships__date_end__gte=timezone.now()).exclude(note__inactivity_reason='forced')
# Filter with permission backend
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view"))
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix = "__iregex" if valid_regex else "__icontains"
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))
Q(**{f"note__noteuser__user__first_name{suffix}": pattern})
| Q(**{f"note__noteuser__user__last_name{suffix}": pattern})
| Q(**{f"name{suffix}": pattern})
| Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)})
)
else:
note_qs = note_qs.none()
@ -251,15 +296,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
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)
def get_table_data(self):
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity
matched = []
@ -272,8 +311,17 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
note.activity = activity
matched.append(note)
table = EntryTable(data=matched)
context["table"] = table
return matched
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, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity
context["entries"] = Entry.objects.filter(activity=activity)
@ -281,7 +329,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
activities_open = Activity.objects.filter(open=True).filter(
activities_open = Activity.objects.filter(open=True, activity_type__manage_entries=True).filter(
PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
context["activities_open"] = [a for a in activities_open
if PermissionBackend.check_perm(self.request,
@ -315,8 +363,8 @@ X-WR-CALNAME:Kfet Calendar
NAME:Kfet Calendar
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
@ -338,10 +386,10 @@ END:VTIMEZONE
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)}
DTSTART:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_start)}
DTEND:{"{:%Y%m%dT%H%M%S}Z".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) + """
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + f"""
-- {activity.organizer.name}
END:VEVENT
"""

View File

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

View File

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

42
apps/api/filters.py Normal file
View File

@ -0,0 +1,42 @@
import re
from functools import lru_cache
from rest_framework.filters import SearchFilter
class RegexSafeSearchFilter(SearchFilter):
@lru_cache
def validate_regex(self, search_term) -> bool:
try:
re.compile(search_term)
return True
except re.error:
return False
def get_search_fields(self, view, request):
"""
Ensure that given regex are valid.
If not, we consider that the user is trying to search by substring.
"""
search_fields = super().get_search_fields(view, request)
search_terms = self.get_search_terms(request)
for search_term in search_terms:
if not self.validate_regex(search_term):
# Invalid regex. We assume we don't query by regex but by substring.
search_fields = [f.replace('$', '') for f in search_fields]
break
return search_fields
def get_search_terms(self, request):
"""
Ensure that search field is a valid regex query. If not, we remove extra characters.
"""
terms = super().get_search_terms(request)
if not all(self.validate_regex(term) for term in terms):
# Invalid regex. If a ^ is prefixed to the search term, we remove it.
terms = [term[1:] if term[0] == '^' else term for term in terms]
# Same for dollars.
terms = [term[:-1] if term[-1] == '$' else term for term in terms]
return terms

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import json
@ -12,11 +12,12 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models.fields.files import ImageFieldFile
from django.test import TestCase
from django_filters.rest_framework import DjangoFilterBackend
from phonenumbers import PhoneNumber
from rest_framework.filters import OrderingFilter
from api.filters import RegexSafeSearchFilter
from member.models import Membership, Club
from note.models import NoteClub, NoteUser, Alias, Note
from permission.models import PermissionMask, Permission, Role
from phonenumbers import PhoneNumber
from rest_framework.filters import SearchFilter, OrderingFilter
from .viewsets import ContentTypeViewSet, UserViewSet
@ -87,7 +88,7 @@ class TestAPI(TestCase):
resp = self.client.get(url + f"?ordering=-{field}")
self.assertEqual(resp.status_code, 200)
if SearchFilter in backends:
if RegexSafeSearchFilter in backends:
# Basic search
for field in viewset.search_fields:
obj = self.fix_note_object(obj, field)

View File

@ -1,8 +1,9 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# 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 include
from django.urls import re_path
from rest_framework import routers
from .views import UserInformationView
@ -14,40 +15,48 @@ router = routers.DefaultRouter()
router.register('models', ContentTypeViewSet)
router.register('user', UserViewSet)
if "member" in settings.INSTALLED_APPS:
from member.api.urls import register_members_urls
register_members_urls(router, 'members')
if "member" in settings.INSTALLED_APPS:
if "activity" in settings.INSTALLED_APPS:
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 "food" in settings.INSTALLED_APPS:
from food.api.urls import register_food_urls
register_food_urls(router, 'food')
if "logs" in settings.INSTALLED_APPS:
from logs.api.urls import register_logs_urls
register_logs_urls(router, 'logs')
if "member" in settings.INSTALLED_APPS:
from member.api.urls import register_members_urls
register_members_urls(router, 'members')
if "note" in settings.INSTALLED_APPS:
from note.api.urls import register_note_urls
register_note_urls(router, 'note')
if "permission" in settings.INSTALLED_APPS:
from permission.api.urls import register_permission_urls
register_permission_urls(router, 'permission')
if "treasury" in settings.INSTALLED_APPS:
from treasury.api.urls import register_treasury_urls
register_treasury_urls(router, 'treasury')
if "wei" in settings.INSTALLED_APPS:
from wei.api.urls import register_wei_urls
register_wei_urls(router, 'wei')
if "wrapped" in settings.INSTALLED_APPS:
from wrapped.api.urls import register_wrapped_urls
register_wrapped_urls(router, 'wrapped')
app_name = 'api'
# Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
url('^', include(router.urls)),
url('^me/', UserInformationView.as_view()),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
re_path('^', include(router.urls)),
re_path('^me/', UserInformationView.as_view()),
re_path('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]

View File

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

View File

@ -1,19 +1,29 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import re
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.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
from permission.backends import PermissionBackend
from note.models import Alias
from .filters import RegexSafeSearchFilter
from .serializers import UserSerializer, ContentTypeSerializer
def is_regex(pattern):
try:
re.compile(pattern)
return True
except (re.error, TypeError):
return False
class ReadProtectedModelViewSet(ModelViewSet):
"""
Protect a ModelViewSet by filtering the objects that the user cannot see.
@ -60,34 +70,38 @@ class UserViewSet(ReadProtectedModelViewSet):
if "search" in self.request.GET:
pattern = self.request.GET["search"]
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix = "__iregex" if valid_regex else "__istartswith"
prefix = "^" if valid_regex else ""
# 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
Q(**{f"note__alias__name{suffix}": prefix + pattern})
).union(
queryset.filter(
# Match with normalization
Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
& ~Q(**{f"note__alias__name{suffix}": prefix + 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)
Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
& ~Q(**{f"note__alias__name{suffix}": prefix + 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)
(Q(**{f"last_name{suffix}": prefix + pattern}) | Q(**{f"first_name{suffix}": prefix + pattern}))
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
& ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
),
all=True,
)
@ -107,6 +121,6 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
queryset = ContentType.objects.order_by('id')
serializer_class = ContentTypeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['id', 'app_label', 'model', ]
search_fields = ['$app_label', '$model', ]

0
apps/food/__init__.py Normal file
View File

37
apps/food/admin.py Normal file
View File

@ -0,0 +1,37 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.db import transaction
from note_kfet.admin import admin_site
from .models import Allergen, BasicFood, QRCode, TransformedFood
@admin.register(QRCode, site=admin_site)
class QRCodeAdmin(admin.ModelAdmin):
pass
@admin.register(BasicFood, site=admin_site)
class BasicFoodAdmin(admin.ModelAdmin):
@transaction.atomic
def save_related(self, *args, **kwargs):
ans = super().save_related(*args, **kwargs)
args[1].instance.update()
return ans
@admin.register(TransformedFood, site=admin_site)
class TransformedFoodAdmin(admin.ModelAdmin):
exclude = ["allergens", "expiry_date"]
@transaction.atomic
def save_related(self, request, form, *args, **kwargs):
super().save_related(request, form, *args, **kwargs)
form.instance.update()
@admin.register(Allergen, site=admin_site)
class AllergenAdmin(admin.ModelAdmin):
pass

View File

View File

@ -0,0 +1,50 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Allergen, BasicFood, QRCode, TransformedFood
class AllergenSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Allergen.
The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
"""
class Meta:
model = Allergen
fields = '__all__'
class BasicFoodSerializer(serializers.ModelSerializer):
"""
REST API Serializer for BasicFood.
The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
"""
class Meta:
model = BasicFood
fields = '__all__'
class QRCodeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for QRCode.
The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
"""
class Meta:
model = QRCode
fields = '__all__'
class TransformedFoodSerializer(serializers.ModelSerializer):
"""
REST API Serializer for TransformedFood.
The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
"""
class Meta:
model = TransformedFood
fields = '__all__'

14
apps/food/api/urls.py Normal file
View File

@ -0,0 +1,14 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import AllergenViewSet, BasicFoodViewSet, QRCodeViewSet, TransformedFoodViewSet
def register_food_urls(router, path):
"""
Configure router for Food REST API.
"""
router.register(path + '/allergen', AllergenViewSet)
router.register(path + '/basic_food', BasicFoodViewSet)
router.register(path + '/qrcode', QRCodeViewSet)
router.register(path + '/transformed_food', TransformedFoodViewSet)

61
apps/food/api/views.py Normal file
View File

@ -0,0 +1,61 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from .serializers import AllergenSerializer, BasicFoodSerializer, QRCodeSerializer, TransformedFoodSerializer
from ..models import Allergen, BasicFood, QRCode, TransformedFood
class AllergenViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Allergen` objects, serialize it to JSON with the given serializer,
then render it on /api/food/allergen/
"""
queryset = Allergen.objects.order_by('id')
serializer_class = AllergenSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class BasicFoodViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer,
then render it on /api/food/basic_food/
"""
queryset = BasicFood.objects.order_by('id')
serializer_class = BasicFoodSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class QRCodeViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `QRCode` objects, serialize it to JSON with the given serializer,
then render it on /api/food/qrcode/
"""
queryset = QRCode.objects.order_by('id')
serializer_class = QRCodeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['qr_code_number', ]
search_fields = ['$qr_code_number', ]
class TransformedFoodViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
then render it on /api/food/transformed_food/
"""
queryset = TransformedFood.objects.order_by('id')
serializer_class = TransformedFoodSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]

11
apps/food/apps.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
from django.apps import AppConfig
class FoodkfetConfig(AppConfig):
name = 'food'
verbose_name = _('food')

114
apps/food/forms.py Normal file
View File

@ -0,0 +1,114 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from random import shuffle
from django import forms
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from member.models import Club
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from note_kfet.inputs import Autocomplete
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from .models import BasicFood, QRCode, TransformedFood
class AddIngredientForms(forms.ModelForm):
"""
Form for add an ingredient
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['ingredient'].queryset = self.fields['ingredient'].queryset.filter(
polymorphic_ctype__model='transformedfood',
is_ready=False,
is_active=True,
was_eaten=False,
)
# Caution, the logic is inverted here, we flip the logic on saving in AddIngredientView
self.fields['is_active'].initial = True
self.fields['is_active'].label = _("Fully used")
class Meta:
model = TransformedFood
fields = ('ingredient', 'is_active')
class BasicFoodForms(forms.ModelForm):
"""
Form for add non-transformed food
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
self.fields['name'].required = True
self.fields['owner'].required = True
# Some example
self.fields['name'].widget.attrs.update({"placeholder": _("Pasta METRO 5kg")})
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
shuffle(clubs)
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
class Meta:
model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'is_active', 'was_eaten', 'allergens',)
widgets = {
"owner": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
'expiry_date': DateTimePickerInput(),
}
class QRCodeForms(forms.ModelForm):
"""
Form for create QRCode
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
is_active=True,
was_eaten=False,
polymorphic_ctype__model='transformedfood',
)
class Meta:
model = QRCode
fields = ('food_container',)
class TransformedFoodForms(forms.ModelForm):
"""
Form for add transformed food
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
self.fields['name'].required = True
self.fields['owner'].required = True
self.fields['creation_date'].required = True
self.fields['creation_date'].initial = timezone.now
self.fields['is_active'].initial = True
self.fields['is_ready'].initial = False
self.fields['was_eaten'].initial = False
# Some example
self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
shuffle(clubs)
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
class Meta:
model = TransformedFood
fields = ('name', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life')
widgets = {
"owner": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
'creation_date': DateTimePickerInput(),
}

View File

@ -0,0 +1,84 @@
# Generated by Django 2.2.28 on 2024-07-05 08:57
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('member', '0011_profile_vss_charter_read'),
]
operations = [
migrations.CreateModel(
name='Allergen',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
],
options={
'verbose_name': 'Allergen',
'verbose_name_plural': 'Allergens',
},
),
migrations.CreateModel(
name='Food',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('expiry_date', models.DateTimeField(verbose_name='expiry date')),
('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')),
('is_ready', models.BooleanField(default=False, verbose_name='is ready')),
('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='owner')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_food.food_set+', to='contenttypes.ContentType')),
],
options={
'verbose_name': 'foods',
},
),
migrations.CreateModel(
name='BasicFood',
fields=[
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
('date_type', models.CharField(choices=[('DLC', 'DLC'), ('DDM', 'DDM')], max_length=255)),
('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')),
],
options={
'verbose_name': 'Basic food',
'verbose_name_plural': 'Basic foods',
},
bases=('food.food',),
),
migrations.CreateModel(
name='QRCode',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('qr_code_number', models.PositiveIntegerField(unique=True, verbose_name='QR-code number')),
('food_container', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='QR_code', to='food.Food', verbose_name='food container')),
],
options={
'verbose_name': 'QR-code',
'verbose_name_plural': 'QR-codes',
},
),
migrations.CreateModel(
name='TransformedFood',
fields=[
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
('creation_date', models.DateTimeField(verbose_name='creation date')),
('is_active', models.BooleanField(default=True, verbose_name='is active')),
('ingredient', models.ManyToManyField(blank=True, related_name='transformed_ingredient_inv', to='food.Food', verbose_name='transformed ingredient')),
],
options={
'verbose_name': 'Transformed food',
'verbose_name_plural': 'Transformed foods',
},
bases=('food.food',),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.28 on 2024-07-06 20:37
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='transformedfood',
name='shelf_life',
field=models.DurationField(default=datetime.timedelta(days=3), verbose_name='shelf life'),
),
]

View File

@ -0,0 +1,62 @@
from django.db import migrations
def create_14_mandatory_allergens(apps, schema_editor):
"""
There are 14 mandatory allergens, they are pre-injected
"""
Allergen = apps.get_model("food", "allergen")
Allergen.objects.get_or_create(
name="Gluten",
)
Allergen.objects.get_or_create(
name="Fruits à coques",
)
Allergen.objects.get_or_create(
name="Crustacés",
)
Allergen.objects.get_or_create(
name="Céléri",
)
Allergen.objects.get_or_create(
name="Oeufs",
)
Allergen.objects.get_or_create(
name="Moutarde",
)
Allergen.objects.get_or_create(
name="Poissons",
)
Allergen.objects.get_or_create(
name="Soja",
)
Allergen.objects.get_or_create(
name="Lait",
)
Allergen.objects.get_or_create(
name="Sulfites",
)
Allergen.objects.get_or_create(
name="Sésame",
)
Allergen.objects.get_or_create(
name="Lupin",
)
Allergen.objects.get_or_create(
name="Arachides",
)
Allergen.objects.get_or_create(
name="Mollusques",
)
class Migration(migrations.Migration):
dependencies = [
('food', '0002_transformedfood_shelf_life'),
]
operations = [
migrations.RunPython(create_14_mandatory_allergens),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 2.2.28 on 2024-08-13 21:58
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('food', '0003_create_14_allergens_mandatory'),
]
operations = [
migrations.RemoveField(
model_name='transformedfood',
name='is_active',
),
migrations.AddField(
model_name='food',
name='is_active',
field=models.BooleanField(default=True, verbose_name='is active'),
),
migrations.AlterField(
model_name='qrcode',
name='food_container',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='QR_code', to='food.Food', verbose_name='food container'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('food', '0004_auto_20240813_2358'),
]
operations = [
migrations.AlterField(
model_name='food',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
]

View File

226
apps/food/models.py Normal file
View File

@ -0,0 +1,226 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
from django.db import models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club
from polymorphic.models import PolymorphicModel
class QRCode(models.Model):
"""
An QRCode model
"""
qr_code_number = models.PositiveIntegerField(
verbose_name=_("QR-code number"),
unique=True,
)
food_container = models.ForeignKey(
'Food',
on_delete=models.CASCADE,
related_name='QR_code',
verbose_name=_('food container'),
)
class Meta:
verbose_name = _("QR-code")
verbose_name_plural = _("QR-codes")
def __str__(self):
return _("QR-code number {qr_code_number}").format(qr_code_number=self.qr_code_number)
class Allergen(models.Model):
"""
A list of allergen and alimentary restrictions
"""
name = models.CharField(
verbose_name=_('name'),
max_length=255,
)
class Meta:
verbose_name = _('Allergen')
verbose_name_plural = _('Allergens')
def __str__(self):
return self.name
class Food(PolymorphicModel):
name = models.CharField(
verbose_name=_('name'),
max_length=255,
)
owner = models.ForeignKey(
Club,
on_delete=models.PROTECT,
related_name='+',
verbose_name=_('owner'),
)
allergens = models.ManyToManyField(
Allergen,
blank=True,
verbose_name=_('allergen'),
)
expiry_date = models.DateTimeField(
verbose_name=_('expiry date'),
null=False,
)
was_eaten = models.BooleanField(
default=False,
verbose_name=_('was eaten'),
)
# is_ready != is_active : is_ready signifie que la nourriture est prête à être manger,
# is_active signifie que la nourriture n'est pas encore archivé
# il sert dans les cas où il est plus intéressant que de l'open soit conservé (confiture par ex)
is_ready = models.BooleanField(
default=False,
verbose_name=_('is ready'),
)
is_active = models.BooleanField(
default=True,
verbose_name=_('is active'),
)
def __str__(self):
return self.name
@transaction.atomic
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
return super().save(force_insert, force_update, using, update_fields)
class Meta:
verbose_name = _('food')
verbose_name = _('foods')
class BasicFood(Food):
"""
Food which has been directly buy on supermarket
"""
date_type = models.CharField(
max_length=255,
choices=(
("DLC", "DLC"),
("DDM", "DDM"),
)
)
arrival_date = models.DateTimeField(
verbose_name=_('arrival date'),
default=timezone.now,
)
# label = models.ImageField(
# verbose_name=_('food label'),
# max_length=255,
# blank=False,
# null=False,
# upload_to='label/',
# )
@transaction.atomic
def update_allergens(self):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
parent.update_allergens()
@transaction.atomic
def update_expiry_date(self):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
parent.update_expiry_date()
@transaction.atomic
def update(self):
self.update_allergens()
self.update_expiry_date()
class Meta:
verbose_name = _('Basic food')
verbose_name_plural = _('Basic foods')
class TransformedFood(Food):
"""
Transformed food are a mix between basic food and meal
"""
creation_date = models.DateTimeField(
verbose_name=_('creation date'),
)
ingredient = models.ManyToManyField(
Food,
blank=True,
symmetrical=False,
related_name='transformed_ingredient_inv',
verbose_name=_('transformed ingredient'),
)
# Without microbiological analyzes, the storage time is 3 days
shelf_life = models.DurationField(
verbose_name=_("shelf life"),
default=timedelta(days=3),
)
@transaction.atomic
def archive(self):
# When a meal are archived, if it was eaten, update ingredient fully used for this meal
raise NotImplementedError
@transaction.atomic
def update_allergens(self):
# When allergens are changed, simply update the parents' allergens
old_allergens = list(self.allergens.all())
self.allergens.clear()
for ingredient in self.ingredient.iterator():
self.allergens.set(self.allergens.union(ingredient.allergens.all()))
if old_allergens == list(self.allergens.all()):
return
super().save()
# update parents
for parent in self.transformed_ingredient_inv.iterator():
parent.update_allergens()
@transaction.atomic
def update_expiry_date(self):
# When expiry_date is changed, simply update the parents' expiry_date
old_expiry_date = self.expiry_date
self.expiry_date = self.creation_date + self.shelf_life
for ingredient in self.ingredient.iterator():
self.expiry_date = min(self.expiry_date, ingredient.expiry_date)
if old_expiry_date == self.expiry_date:
return
super().save()
# update parents
for parent in self.transformed_ingredient_inv.iterator():
parent.update_expiry_date()
@transaction.atomic
def update(self):
self.update_allergens()
self.update_expiry_date()
@transaction.atomic
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
class Meta:
verbose_name = _('Transformed food')
verbose_name_plural = _('Transformed foods')

19
apps/food/tables.py Normal file
View File

@ -0,0 +1,19 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django_tables2 import A
from .models import TransformedFood
class TransformedFoodTable(tables.Table):
name = tables.LinkColumn(
'food:food_view',
args=[A('pk'), ],
)
class Meta:
model = TransformedFood
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', "owner", "allergens", "expiry_date")

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" id="form">
<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,37 @@
{% 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 }} {{ food.name }}
</h3>
<div class="card-body">
<ul>
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
<li><p>{% trans 'Arrival date' %} : {{ food.arrival_date }}</p></li>
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }} ({{ food.date_type }})</p></li>
<li>{% trans 'Allergens' %} :</li>
<ul>
{% for allergen in food.allergens.iterator %}
<li>{{ allergen.name }}</li>
{% endfor %}
</ul>
<p>
<li><p>{% trans 'Active' %} : {{ food.is_active }}<p></li>
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}<p></li>
</ul>
{% if can_update %}
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=food.pk %}">{% trans 'Update' %}</a>
{% endif %}
{% if can_add_ingredient %}
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans 'Add to a meal' %}
</a>
{% endif %}
</div>
</div>
{% 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" id="form">
<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,55 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% 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" id="form">
<a class="btn btn-sm btn-success" href="{% url "food:qrcode_basic_create" slug=slug %}">
{% trans 'New basic food' %}
</a>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
<div class="card-body" id="profile_infos">
<h4>{% trans "Copy constructor" %}</h4>
<table class="table">
<thead>
<tr>
<th class="orderable">
{% trans "Name" %}
</th>
<th class="orderable">
{% trans "Owner" %}
</th>
<th class="orderable">
{% trans "Arrival date" %}
</th>
<th class="orderable">
{% trans "Expiry date" %}
</th>
</tr>
</thead>
<tbody>
{% for basic in last_basic %}
<tr>
<td><a href="{% url "food:qrcode_basic_create" slug=slug %}?copy={{ basic.pk }}">{{ basic.name }}</a></td>
<td>{{ basic.owner }}</td>
<td>{{ basic.arrival_date }}</td>
<td>{{ basic.expiry_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,39 @@
{% 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 }} {% trans 'number' %} {{ qrcode.qr_code_number }}
</h3>
<div class="card-body">
<ul>
<li><p>{% trans 'Name' %} : {{ qrcode.food_container.name }}</p></li>
<li><p>{% trans 'Owner' %} : {{ qrcode.food_container.owner }}</p></li>
<li><p>{% trans 'Expiry date' %} : {{ qrcode.food_container.expiry_date }}</p></li>
</ul>
{% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %}
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=qrcode.food_container.pk %}" data-turbolinks="false">
{% trans 'Update' %}
</a>
{% elif can_update_transformed %}
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=qrcode.food_container.pk %}">
{% trans 'Update' %}
</a>
{% endif %}
{% if can_view_detail %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_view" pk=qrcode.food_container.pk %}">
{% trans 'View details' %}
</a>
{% endif %}
{% if can_add_ingredient %}
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=qrcode.food_container.pk %}">
{% trans 'Add to a meal' %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% 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 }} {{ food.name }}
</h3>
<div class="card-body">
<ul>
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
{% if can_see_ready %}
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
{% endif %}
<li><p>{% trans 'Creation date' %} : {{ food.creation_date }}</p></li>
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }}</p></li>
<li>{% trans 'Allergens' %} :</li>
<ul>
{% for allergen in food.allergens.iterator %}
<li>{{ allergen.name }}</li>
{% endfor %}
</ul>
<p>
<li>{% trans 'Ingredients' %} :</li>
<ul>
{% for ingredient in food.ingredient.iterator %}
<li><a href="{% url "food:food_view" pk=ingredient.pk %}">{{ ingredient.name }}</a></li>
{% endfor %}
</ul>
<p>
<li><p>{% trans 'Shelf life' %} : {{ food.shelf_life }}</p></li>
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
<li><p>{% trans 'Active' %} : {{ food.is_active }}</p></li>
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}</p></li>
</ul>
{% if can_update %}
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=food.pk %}">
{% trans 'Update' %}
</a>
{% endif %}
{% if can_add_ingredient %}
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans 'Add to a meal' %}
</a>
{% endif %}
</div>
</div>
{% 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" id="form">
<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,60 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Meal served" %}
</h3>
{% if can_create_meal %}
<div class="card-footer">
<a class="btn btn-sm btn-success" href="{% url 'food:transformed_create' %}" data-turbolinks="false">
{% trans 'New meal' %}
</a>
</div>
{% endif %}
{% if served.data %}
{% render_table served %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no meal served." %}
</div>
</div>
{% endif %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Open" %}
</h3>
{% if open.data %}
{% render_table open %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no free meal." %}
</div>
</div>
{% endif %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "All meals" %}
</h3>
{% if table.data %}
{% render_table table %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no meal." %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

3
apps/food/tests.py Normal file
View File

@ -0,0 +1,3 @@
# from django.test import TestCase
# Create your tests here.

21
apps/food/urls.py Normal file
View File

@ -0,0 +1,21 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from . import views
app_name = 'food'
urlpatterns = [
path('', views.TransformedListView.as_view(), name='food_list'),
path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'),
path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'),
path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'),
path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'),
path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'),
path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'),
path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
]

421
apps/food/views.py Normal file
View File

@ -0,0 +1,421 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import transaction
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django_tables2.views import MultiTableMixin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.views.generic import DetailView, UpdateView
from django.views.generic.list import ListView
from django.forms import HiddenInput
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms
from .models import BasicFood, Food, QRCode, TransformedFood
from .tables import TransformedFoodTable
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
"""
A view to add an ingredient
"""
model = Food
template_name = 'food/add_ingredient_form.html'
extra_context = {"title": _("Add the ingredient")}
form_class = AddIngredientForms
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["pk"] = self.kwargs["pk"]
return context
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
food = Food.objects.get(pk=self.kwargs['pk'])
add_ingredient_form = AddIngredientForms(data=self.request.POST)
if food.is_ready:
form.add_error(None, _("The product is already prepared"))
return self.form_invalid(form)
if not add_ingredient_form.is_valid():
return self.form_invalid(form)
# We flip logic ""fully used = not is_active""
food.is_active = not food.is_active
# Save the aliment and the allergens associed
for transformed_pk in self.request.POST.getlist('ingredient'):
transformed = TransformedFood.objects.get(pk=transformed_pk)
if not transformed.is_ready:
transformed.ingredient.add(food)
transformed.update()
food.save()
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self, **kwargs):
return reverse('food:food_list')
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to update a basic food
"""
model = BasicFood
form_class = BasicFoodForms
template_name = 'food/basicfood_form.html'
extra_context = {"title": _("Update an aliment")}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
basic_food_form = BasicFoodForms(data=self.request.POST)
if not basic_food_form.is_valid():
return self.form_invalid(form)
ans = super().form_valid(form)
form.instance.update()
return ans
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:food_view', kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
return context
class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
A view to see a food
"""
model = Food
extra_context = {"title": _("Details of:")}
context_object_name = "food"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food")
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
return context
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
#####################################################################
# TO DO
# - this feature is very pratical for meat or fish, nevertheless we can implement this later
# - fix picture save
# - implement solution crop and convert image (reuse or recode ImageForm from members apps)
#####################################################################
"""
A view to add a basic food with a qrcode
"""
model = BasicFood
form_class = BasicFoodForms
template_name = 'food/basicfood_form.html'
extra_context = {"title": _("Add a new basic food with QRCode")}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
basic_food_form = BasicFoodForms(data=self.request.POST)
if not basic_food_form.is_valid():
return self.form_invalid(form)
# Save the aliment and the allergens associed
basic_food = form.save(commit=False)
# We assume the date of labeling and the same as the date of arrival
basic_food.arrival_date = timezone.now()
basic_food.is_ready = False
basic_food.is_active = True
basic_food.was_eaten = False
basic_food._force_save = True
basic_food.save()
basic_food.refresh_from_db()
qrcode = QRCode()
qrcode.qr_code_number = self.kwargs['slug']
qrcode.food_container = basic_food
qrcode.save()
return super().form_valid(form)
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
def get_sample_object(self):
# We choose a club which may work or BDE else
owner_id = 1
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id)
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
owner_id = club_id
return BasicFood(
name="",
expiry_date=timezone.now(),
owner_id=owner_id,
)
def get_context_data(self, **kwargs):
# Some field are hidden on create
context = super().get_context_data(**kwargs)
form = context['form']
form.fields['is_active'].widget = HiddenInput()
form.fields['was_eaten'].widget = HiddenInput()
copy = self.request.GET.get('copy', None)
if copy is not None:
basic = BasicFood.objects.get(pk=copy)
for field in ['date_type', 'expiry_date', 'name', 'owner']:
form.fields[field].initial = getattr(basic, field)
for field in ['allergens']:
form.fields[field].initial = getattr(basic, field).all()
return context
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
A view to add a new qrcode
"""
model = QRCode
template_name = 'food/create_qrcode_form.html'
form_class = QRCodeForms
extra_context = {"title": _("Add a new QRCode")}
def get(self, *args, **kwargs):
qrcode = kwargs["slug"]
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs))
else:
return super().get(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["slug"] = self.kwargs["slug"]
context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10]
return context
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
qrcode_food_form = QRCodeForms(data=self.request.POST)
if not qrcode_food_form.is_valid():
return self.form_invalid(form)
# Save the qrcode
qrcode = form.save(commit=False)
qrcode.qr_code_number = self.kwargs["slug"]
qrcode._force_save = True
qrcode.save()
qrcode.refresh_from_db()
qrcode.food_container.save()
return super().form_valid(form)
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
def get_sample_object(self):
return QRCode(
qr_code_number=self.kwargs["slug"],
food_container_id=1
)
class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
A view to see a qrcode
"""
model = QRCode
extra_context = {"title": _("QRCode")}
context_object_name = "qrcode"
slug_field = "qr_code_number"
def get(self, *args, **kwargs):
qrcode = kwargs["slug"]
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
return super().get(*args, **kwargs)
else:
return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
qr_code_number = self.kwargs['slug']
qrcode = self.model.objects.get(qr_code_number=qr_code_number)
model = qrcode.food_container.polymorphic_ctype.model
if model == "basicfood":
context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood")
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood")
if model == "transformedfood":
context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood")
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
return context
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
A view to add a tranformed food
"""
model = TransformedFood
template_name = 'food/transformedfood_form.html'
form_class = TransformedFoodForms
extra_context = {"title": _("Add a new meal")}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
transformed_food_form = TransformedFoodForms(data=self.request.POST)
if not transformed_food_form.is_valid():
return self.form_invalid(form)
# Save the aliment and allergens associated
transformed_food = form.save(commit=False)
transformed_food.expiry_date = transformed_food.creation_date
transformed_food.is_active = True
transformed_food.is_ready = False
transformed_food.was_eaten = False
transformed_food._force_save = True
transformed_food.save()
transformed_food.refresh_from_db()
ans = super().form_valid(form)
transformed_food.update()
return ans
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:food_view', kwargs={"pk": self.object.pk})
def get_sample_object(self):
# We choose a club which may work or BDE else
owner_id = 1
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = TransformedFood(name="",
creation_date=timezone.now(),
expiry_date=timezone.now(),
owner_id=club_id)
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
owner_id = club_id
break
return TransformedFood(
name="",
owner_id=owner_id,
creation_date=timezone.now(),
expiry_date=timezone.now(),
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Some field are hidden on create
form = context['form']
form.fields['is_active'].widget = HiddenInput()
form.fields['is_ready'].widget = HiddenInput()
form.fields['was_eaten'].widget = HiddenInput()
form.fields['shelf_life'].widget = HiddenInput()
return context
class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to update transformed product
"""
model = TransformedFood
template_name = 'food/transformedfood_form.html'
form_class = TransformedFoodForms
extra_context = {'title': _('Update a meal')}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
transformedfood_form = TransformedFoodForms(data=self.request.POST)
if not transformedfood_form.is_valid():
return self.form_invalid(form)
ans = super().form_valid(form)
form.instance.update()
return ans
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:food_view', kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
return context
class TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
Displays ready TransformedFood
"""
model = TransformedFood
tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable]
extra_context = {"title": _("Transformed food")}
def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct()
def get_tables(self):
tables = super().get_tables()
tables[0].prefix = "all-"
tables[1].prefix = "open-"
tables[2].prefix = "served-"
return tables
def get_tables_data(self):
# first table = all transformed food, second table = free, third = served
return [
self.get_queryset().order_by("-creation_date"),
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
.distinct()
.order_by("-creation_date"),
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
.distinct()
.order_by("-creation_date")
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# We choose a club which should work
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = TransformedFood(
name="",
owner_id=club_id,
creation_date=timezone.now(),
expiry_date=timezone.now(),
)
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
context['can_create_meal'] = True
break
tables = context["tables"]
for name, table in zip(["table", "open", "served"], tables):
context[name] = table
return context

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
@ -76,9 +76,6 @@ class Changelog(models.Model):
verbose_name=_('timestamp'),
)
def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed."))
class Meta:
verbose_name = _("changelog")
verbose_name_plural = _("changelogs")
@ -86,3 +83,6 @@ class Changelog(models.Model):
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))
def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed."))

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
@ -56,13 +56,13 @@ def save_object(sender, instance, **kwargs):
# noinspection PyProtectedMember
previous = instance._previous
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
request = get_current_request()
if request is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
@ -134,13 +134,13 @@ def delete_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
request = get_current_request()
if request is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,9 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.filters import OrderingFilter
from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet
from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
@ -17,7 +18,7 @@ class ProfileViewSet(ReadProtectedModelViewSet):
"""
queryset = Profile.objects.order_by('id')
serializer_class = ProfileSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section",
'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
@ -34,7 +35,7 @@ class ClubViewSet(ReadProtectedModelViewSet):
"""
queryset = Club.objects.order_by('id')
serializer_class = ClubSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
'membership_duration', 'membership_start', 'membership_end', ]
@ -49,7 +50,7 @@ class MembershipViewSet(ReadProtectedModelViewSet):
"""
queryset = Membership.objects.order_by('id')
serializer_class = MembershipSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter]
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name',
'user__username', 'user__last_name', 'user__first_name', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name',

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from cas_server.auth import DjangoAuthUser # pragma: no cover

View File

@ -1,9 +1,9 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import io
from PIL import Image, ImageSequence
from bootstrap_datepicker_plus.widgets import DatePickerInput
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm
@ -13,8 +13,9 @@ from django.forms import CheckboxSelectMultiple
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias
from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput
from note_kfet.inputs import Autocomplete, AmountInput
from permission.models import PermissionMask, Role
from PIL import Image, ImageSequence
from .models import Profile, Club, Membership
@ -32,7 +33,7 @@ class UserForm(forms.ModelForm):
# Django usernames can only contain letters, numbers, @, ., +, - and _.
# We want to allow users to have uncommon and unpractical usernames:
# That is their problem, and we have normalized aliases for us.
return super()._get_validation_exclusions() + ["username"]
return super()._get_validation_exclusions() | {"username"}
class Meta:
model = User
@ -43,10 +44,18 @@ class ProfileForm(forms.ModelForm):
"""
A form for the extras field provided by the :model:`member.Profile` model.
"""
# Remove widget=forms.HiddenInput() if you want to use report frequency.
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
VSS_charter_read = forms.BooleanField(
required=True,
label=_("Anti-VSS (<em>Violences Sexistes et Sexuelles</em>) charter read and approved"),
help_text=_("Tick after having read and accepted the anti-VSS charter \
<a href=https://perso.crans.org/club-bde/Charte-anti-VSS.pdf target=_blank> available here in pdf</a>")
)
def clean_promotion(self):
promotion = self.cleaned_data["promotion"]
if promotion > timezone.now().year:
@ -68,7 +77,8 @@ class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = '__all__'
exclude = ('user', 'email_confirmed', 'registration_valid', )
# Remove ml_[asso]_registration from exclude if the concerned association uses nk20 to manage its mailing list.
exclude = ('user', 'email_confirmed', 'registration_valid', 'ml_sport_registration', )
class ImageForm(forms.Form):
@ -114,7 +124,7 @@ class ImageForm(forms.Form):
frame = frame.crop((x, y, x + w, y + h))
frame = frame.resize(
(settings.PIC_WIDTH, settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS,
Image.LANCZOS,
)
frames.append(frame)
@ -131,6 +141,9 @@ class ImageForm(forms.Form):
return cleaned_data
def is_valid(self):
return super().is_valid() or super().clean().get('image') is None
class ClubForm(forms.ModelForm):
def clean(self):
@ -144,7 +157,7 @@ class ClubForm(forms.ModelForm):
class Meta:
model = Club
fields = '__all__'
exclude = ("add_registration_form",)
widgets = {
"membership_fee_paid": AmountInput(),
"membership_fee_unpaid": AmountInput(),
@ -200,9 +213,9 @@ class MembershipForm(forms.ModelForm):
class Meta:
model = Membership
fields = ('user', 'date_start')
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
# Le champ d'utilisateur⋅rice est remplacé par un champ d'auto-complétion.
# Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion
# et récupère les noms d'utilisateur valides
# et récupère les noms d'utilisateur⋅rices valides
widgets = {
'user':
Autocomplete(

View File

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

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-08-23 21:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0009_auto_20220904_2325'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2023, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-08-31 09:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0010_new_default_year'),
]
operations = [
migrations.AddField(
model_name='profile',
name='VSS_charter_read',
field=models.BooleanField(default=False, verbose_name='VSS charter read'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2024-07-15 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0011_profile_vss_charter_read'),
]
operations = [
migrations.AddField(
model_name='club',
name='add_registration_form',
field=models.BooleanField(default=False, verbose_name='add to registration form'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2024-08-01 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0012_club_add_registration_form'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2024, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2024-08-07 12:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0012_club_add_registration_form'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2024, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
@ -28,7 +28,6 @@ class Profile(models.Model):
We do not want to patch the Django Contrib :model:`auth.User`model;
so this model add an user profile with additional information.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@ -134,6 +133,22 @@ class Profile(models.Model):
default=False,
)
VSS_charter_read = models.BooleanField(
verbose_name=_("VSS charter read"),
default=False
)
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
indexes = [models.Index(fields=['user'])]
def __str__(self):
return str(self.user)
def get_absolute_url(self):
return reverse('member:user_detail', args=(self.user_id,))
@property
def ens_year(self):
"""
@ -158,17 +173,6 @@ class Profile(models.Model):
return SogeCredit.objects.filter(user=self.user, credit_transaction__isnull=False).exists()
return False
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
indexes = [models.Index(fields=['user'])]
def get_absolute_url(self):
return reverse('member:user_detail', args=(self.user_id,))
def __str__(self):
return str(self.user)
def send_email_validation_link(self):
subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
token = email_validation_token.make_token(self.user)
@ -200,9 +204,11 @@ class Club(models.Model):
max_length=255,
unique=True,
)
email = models.EmailField(
verbose_name=_('email'),
)
parent_club = models.ForeignKey(
'self',
null=True,
@ -253,25 +259,17 @@ class Club(models.Model):
help_text=_('Maximal date of a membership, after which members must renew it.'),
)
def update_membership_dates(self):
"""
This function is called each time the club detail view is displayed.
Update the year of the membership dates.
"""
if not self.membership_start or not self.membership_end:
return
add_registration_form = models.BooleanField(
verbose_name=_("add to registration form"),
default=False,
)
today = datetime.date.today()
class Meta:
verbose_name = _("club")
verbose_name_plural = _("clubs")
if (today - self.membership_start).days >= 365:
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
if self.membership_end:
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
self._force_save = True
self.save(force_update=True)
def __str__(self):
return self.name
@transaction.atomic
def save(self, force_insert=False, force_update=False, using=None,
@ -284,16 +282,36 @@ class Club(models.Model):
self.membership_end = None
super().save(force_insert, force_update, update_fields)
class Meta:
verbose_name = _("club")
verbose_name_plural = _("clubs")
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse_lazy('member:club_detail', args=(self.pk,))
def update_membership_dates(self):
"""
This function is called each time the club detail view is displayed.
Update the year of the membership dates.
"""
if not self.membership_start or not self.membership_end:
return
today = datetime.date.today()
# Avoid any problems on February 29
if self.membership_start.month == 2 and self.membership_start.day == 29:
self.membership_start -= datetime.timedelta(days=1)
if self.membership_end.month == 2 and self.membership_end.day == 29:
self.membership_end += datetime.timedelta(days=1)
while today >= datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day):
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
if self.membership_end:
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
self._force_save = True
self.save(force_update=True)
class Membership(models.Model):
"""
@ -333,6 +351,66 @@ class Membership(models.Model):
verbose_name=_('fee'),
)
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
indexes = [models.Index(fields=['user'])]
def __str__(self):
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
@transaction.atomic
def save(self, *args, **kwargs):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
# Ensure that club membership dates are valid
old_membership_start = self.club.membership_start
self.club.update_membership_dates()
if self.club.membership_start != old_membership_start:
self.club.save()
created = not self.pk
if not created:
for role in self.roles.all():
club = role.for_club
if club is not None:
if club.pk != self.club_id:
raise ValidationError(_('The role {role} does not apply to the club {club}.')
.format(role=role.name, club=club.name))
else:
if Membership.objects.filter(
user=self.user,
club=self.club,
date_start__lte=self.date_start,
date_end__gte=self.date_start,
).exists():
raise ValidationError(_('User is already a member of the club'))
if self.club.parent_club is not None:
# 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:
raise ValidationError(_('User is not a member of the parent club')
+ ' ' + self.club.parent_club.name)
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) \
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:
self.date_end = self.club.membership_end
super().save(*args, **kwargs)
self.make_transaction()
@property
def valid(self):
"""
@ -402,66 +480,14 @@ class Membership(models.Model):
if self.club.parent_club.name == "BDE":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all())
elif self.club.parent_club.name == "Kfet":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
else:
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
parent_membership.save()
@transaction.atomic
def save(self, *args, **kwargs):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
# Ensure that club membership dates are valid
old_membership_start = self.club.membership_start
self.club.update_membership_dates()
if self.club.membership_start != old_membership_start:
self.club.save()
created = not self.pk
if not created:
for role in self.roles.all():
club = role.for_club
if club is not None:
if club.pk != self.club_id:
raise ValidationError(_('The role {role} does not apply to the club {club}.')
.format(role=role.name, club=club.name))
else:
if Membership.objects.filter(
user=self.user,
club=self.club,
date_start__lte=self.date_start,
date_end__gte=self.date_start,
).exists():
raise ValidationError(_('User is already a member of the club'))
if self.club.parent_club is not None:
# 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:
raise ValidationError(_('User is not a member of the parent club')
+ ' ' + self.club.parent_club.name)
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) \
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:
self.date_end = self.club.membership_end
super().save(*args, **kwargs)
self.make_transaction()
def make_transaction(self):
"""
Create Membership transaction associated to this membership.
@ -499,11 +525,3 @@ class Membership(models.Model):
soge_credit.save()
else:
transaction.save(force_insert=True)
def __str__(self):
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
indexes = [models.Index(fields=['user'])]

View File

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

View File

@ -1,7 +1,7 @@
/**
* On form submit, create a new friendship
*/
function create_trust (e) {
function form_create_trust (e) {
// Do not submit HTML form
e.preventDefault()
@ -14,25 +14,35 @@ function create_trust (e) {
addMsg(gettext("You can't add yourself as a friend"), "danger")
return
}
$.post('/api/note/trust/', {
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
trusting: formData.get('trusting'),
trusted: trusted_alias.note
}).done(function () {
// Reload table
$('#trust_table').load(location.pathname + ' #trust_table')
addMsg(gettext('Friendship successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
create_trust(formData.get('trusting'), trusted_alias.note)
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the alias
* @param button_id:Integer Alias id to remove
* Create a trust between users
* @param trusting:Integer trusting note id
* @param trusted:Integer trusted note id
*/
function create_trust(trusting, trusted) {
$.post('/api/note/trust/', {
trusting: trusting,
trusted: trusted,
csrfmiddlewaretoken: CSRF_TOKEN
}).done(function () {
// Reload tables
$('#trust_table').load(location.pathname + ' #trust_table')
$('#trusted_table').load(location.pathname + ' #trusted_table')
addMsg(gettext('Friendship successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the trust
* @param button_id:Integer Trust id to remove
*/
function delete_button (button_id) {
$.ajax({
@ -42,6 +52,7 @@ function delete_button (button_id) {
}).done(function () {
addMsg(gettext('Friendship successfully deleted'), 'success')
$('#trust_table').load(location.pathname + ' #trust_table')
$('#trusted_table').load(location.pathname + ' #trusted_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
@ -49,5 +60,5 @@ function delete_button (button_id) {
$(document).ready(function () {
// Attach event
document.getElementById('form_trust').addEventListener('submit', create_trust)
document.getElementById('form_trust').addEventListener('submit', form_create_trust)
})

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
@ -42,12 +42,12 @@ class UserTable(tables.Table):
"""
alias = tables.Column()
section = tables.Column(accessor='profile__section')
section = tables.Column(accessor='profile__section', orderable=False)
# Override the column to let replace the URL
email = tables.EmailColumn(linkify=lambda record: "mailto:{}".format(record.email))
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"), orderable=False)
def render_email(self, record, value):
# Replace the email by a dash if the user can't see the profile detail

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }}
</h3>
<div class="card-body">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note">
<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"
@ -66,4 +66,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
roles_obj.change(reloadTable);
});
</script>
{% endblock %}
{% endblock %}

View File

@ -14,6 +14,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
<form method="post" enctype="multipart/form-data" id="formUpload">
{% csrf_token %}
{{ form |crispy }}
{% if user.note.display_image != "pic/default.png" %}
<input type="submit" class="btn btn-primary" value="{% trans "Remove" %}">
{% endif %}
</form>
</div>
<!-- MODAL TO CROP THE IMAGE -->

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_content %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Note friendships" %}
{% trans "Add friends" %}
</h3>
<div class="card-body">
{% if can_create %}
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% render_table trusting %}
</div>
<div class="alert alert-warning card">
<div class="alert alert-warning card mb-3">
{% blocktrans trimmed %}
Adding someone as a friend enables them to initiate transactions coming
from your account (while keeping your balance positive). This is
@ -33,6 +33,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
friends without needing additional rights among them.
{% endblocktrans %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "People having you as a friend" %}
</h3>
{% render_table trusted_by %}
</div>
{% endblock %}
{% block extrajavascript %}

View File

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

View File

@ -183,7 +183,7 @@ class TestMemberships(TestCase):
club = Club.objects.get(name="Kfet")
else:
club = Club.objects.create(
name="Second club " + ("with BDE" if bde_parent else "without BDE"),
name="Second club without BDE",
parent_club=None,
email="newclub@example.com",
require_memberships=True,
@ -291,7 +291,7 @@ class TestMemberships(TestCase):
response = self.client.post(reverse("member:club_manage_roles", args=(self.membership.pk,)), data=dict(
roles=[role.id for role in Role.objects.filter(
Q(name="Membre de club") | Q(name="Trésorier·ère de club") | Q(name="Bureau de club")).all()],
Q(name="Membre de club") | Q(name="Trésorière de club") | Q(name="Bureau de club")).all()],
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.membership.refresh_from_db()
@ -335,6 +335,7 @@ class TestMemberships(TestCase):
ml_sports_registration=True,
ml_art_registration=True,
report_frequency=7,
VSS_charter_read=True
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.assertTrue(User.objects.filter(username="toto changed").exists())

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta, date
@ -8,7 +8,6 @@ from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Q, F
from django.shortcuts import redirect
@ -17,17 +16,19 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin
from django_tables2.views import SingleTableView
from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
from rest_framework.authtoken.models import Token
from api.viewsets import is_regex
from note.models import Alias, NoteClub, NoteUser, Trust
from note.models.transactions import Transaction, SpecialTransaction
from note.tables import HistoryTable, AliasTable, TrustTable
from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable
from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend
from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from django import forms
from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm,\
from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \
CustomAuthenticationForm, MembershipRolesForm
from .models import Club, Membership
from .tables import ClubTable, UserTable, MembershipTable, ClubManagerTable
@ -72,11 +73,24 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.fields['email'].required = True
form.fields['email'].help_text = _("This address must be valid.")
if PermissionBackend.check_perm(self.request, "member.change_profile", context['user_object'].profile):
context['profile_form'] = self.profile_form(instance=context['user_object'].profile,
data=self.request.POST if self.request.POST else None)
if not self.object.profile.report_frequency:
del context['profile_form'].fields["last_report"]
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 profile_form.fields["last_report"]
fields_to_check = list(profile_form.fields.keys())
fields_modifiable = False
# Delete the fields for which the user does not have the permission to modify
for field_name in fields_to_check:
if not PermissionBackend.check_perm(self.request, f"member.change_profile_{field_name}", context['user_object'].profile):
profile_form.fields[field_name].widget = forms.HiddenInput()
else:
fields_modifiable = True
if fields_modifiable:
context['profile_form'] = profile_form
return context
@ -220,16 +234,20 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix = "__iregex" if valid_regex else "__istartswith"
prefix = "^" if valid_regex else ""
qs = qs.filter(
username__iregex="^" + pattern
Q(**{f"username{suffix}": prefix + pattern})
).union(
qs.filter(
(Q(alias__iregex="^" + pattern)
| Q(normalized_alias__iregex="^" + Alias.normalize(pattern))
| Q(last_name__iregex="^" + pattern)
| Q(first_name__iregex="^" + pattern)
(Q(**{f"alias{suffix}": prefix + pattern})
| Q(**{f"normalized_alias{suffix}": prefix + Alias.normalize(pattern)})
| Q(**{f"last_name{suffix}": prefix + pattern})
| Q(**{f"first_name{suffix}": prefix + pattern})
| Q(email__istartswith=pattern))
& ~Q(username__iregex="^" + pattern)
& ~Q(**{f"username{suffix}": prefix + pattern})
), all=True)
else:
qs = qs.none()
@ -244,7 +262,7 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
return context
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
"""
View and manage user trust relationships
"""
@ -253,22 +271,35 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'user_object'
extra_context = {"title": _("Note friendships")}
tables = [
lambda data: TrustTable(data, prefix="trust-"),
lambda data: TrustedTable(data, prefix="trusted-"),
]
def get_tables_data(self):
note = self.object.note
return [
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct(),
note.trusted.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct(),
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["trusting"] = TrustTable(
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
tables = context["tables"]
for name, table in zip(["trusting", "trusted_by"], tables):
context[name] = table
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
trusting=context["object"].note,
trusted=context["object"].note
))
context["widget"] = {
"name": "trusted",
"resetable": True,
"attrs": {
"model_pk": ContentType.objects.get_for_model(Alias).pk,
"class": "autocomplete form-control",
"id": "trusted",
"resetable": True,
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
"name_field": "name",
"placeholder": ""
@ -277,7 +308,7 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
return context
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
"""
View and manage user aliases.
"""
@ -286,12 +317,15 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'user_object'
extra_context = {"title": _("Note aliases")}
table_class = AliasTable
context_table_name = "aliases"
def get_table_data(self):
return self.object.note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct() \
.order_by('normalized_name')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["aliases"] = AliasTable(
note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
.order_by('normalized_name').all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note,
name="",
@ -326,12 +360,15 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
"""Save image to note"""
image = form.cleaned_data['image']
# Rename as a PNG or GIF
extension = image.name.split(".")[-1]
if extension == "gif":
image.name = "{}_pic.gif".format(self.object.note.pk)
if image is None:
image = "pic/default.png"
else:
image.name = "{}_pic.png".format(self.object.note.pk)
# Rename as a PNG or GIF
extension = image.name.split(".")[-1]
if extension == "gif":
image.name = "{}_pic.gif".format(self.object.note.pk)
else:
image.name = "{}_pic.png".format(self.object.note.pk)
# Save
self.object.note.display_image = image
@ -407,10 +444,15 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
if "search" in self.request.GET:
pattern = self.request.GET["search"]
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix = "__iregex" if valid_regex else "__istartswith"
prefix = "^" if valid_regex else ""
qs = qs.filter(
Q(name__iregex=pattern)
| Q(note__alias__name__iregex=pattern)
| Q(note__alias__normalized_name__iregex=Alias.normalize(pattern))
Q(**{f"name{suffix}": prefix + pattern})
| Q(**{f"note__alias__name{suffix}": prefix + pattern})
| Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
)
return qs
@ -507,7 +549,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
return context
class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
"""
Manage aliases of a club.
"""
@ -516,11 +558,16 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'club'
extra_context = {"title": _("Note aliases")}
table_class = AliasTable
context_table_name = "aliases"
def get_table_data(self):
return self.object.note.alias.filter(
PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["aliases"] = AliasTable(note.alias.filter(
PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note,
name="",
@ -753,6 +800,10 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
club = old_membership.club
user = old_membership.user
# Update club membership date
if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
club.update_membership_dates()
form.instance.club = club
# Get form data
@ -820,8 +871,8 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
ret = super().form_valid(form)
member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \
if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \
member_role = Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all() \
if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all() \
if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all()
# Set the same roles as before
if old_membership:
@ -857,7 +908,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
membership.refresh_from_db()
if old_membership.exists():
membership.roles.set(old_membership.get().roles.all())
membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
membership.roles.set(Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
membership.save()
return ret
@ -905,10 +956,15 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
if 'search' in self.request.GET:
pattern = self.request.GET['search']
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix = "__iregex" if valid_regex else "__istartswith"
prefix = "^" if valid_regex else ""
qs = qs.filter(
Q(user__first_name__iregex='^' + pattern)
| Q(user__last_name__iregex='^' + pattern)
| Q(user__note__alias__normalized_name__iregex='^' + Alias.normalize(pattern))
Q(**{f"user__first_name{suffix}": prefix + pattern})
| Q(**{f"user__last_name{suffix}": prefix + pattern})
| Q(**{f"user__note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
)
only_active = "only_active" not in self.request.GET or self.request.GET["only_active"] != '0'

View File

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

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
@ -7,7 +7,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
PolymorphicChildModelFilter, PolymorphicParentModelAdmin
from note_kfet.admin import admin_site
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser, Trust
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
RecurrentTransaction, MembershipTransaction, SpecialTransaction
from .templatetags.pretty_money import pretty_money
@ -21,6 +21,16 @@ class AliasInlines(admin.TabularInline):
model = Alias
class TrustInlines(admin.TabularInline):
"""
Define trusts when editing the trusting note
"""
model = Trust
fk_name = "trusting"
extra = 0
readonly_fields = ("trusted",)
@admin.register(Note, site=admin_site)
class NoteAdmin(PolymorphicParentModelAdmin):
"""
@ -92,7 +102,7 @@ class NoteUserAdmin(PolymorphicChildModelAdmin):
"""
Child for an user note, see NoteAdmin
"""
inlines = (AliasInlines,)
inlines = (AliasInlines, TrustInlines)
# We can't change user after creation or the balance
readonly_fields = ('user', 'balance')

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
@ -11,6 +11,7 @@ from member.models import Membership
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from rest_framework.utils import model_meta
from rest_framework.validators import UniqueTogetherValidator
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias, Trust
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
@ -86,11 +87,9 @@ class TrustSerializer(serializers.ModelSerializer):
class Meta:
model = Trust
fields = '__all__'
def validate(self, attrs):
instance = Trust(**attrs)
instance.clean()
return attrs
validators = [UniqueTogetherValidator(
queryset=Trust.objects.all(), fields=('trusting', 'trusted'),
message=_("This friendship already exists"))]
class AliasSerializer(serializers.ModelSerializer):

View File

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

View File

@ -1,19 +1,19 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from django.conf import settings
from django.db.models import Q
from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter
from rest_framework import status, viewsets
from rest_framework.response import Response
from rest_framework import status
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet, \
is_regex
from permission.backends import PermissionBackend
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer, \
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer, \
TrustSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial, Trust
@ -29,7 +29,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
"""
queryset = Note.objects.order_by('id')
serializer_class = NotePolymorphicSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter, OrderingFilter]
filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ]
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model',
'$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email',
@ -48,10 +48,14 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
.distinct()
alias = self.request.query_params.get("alias", ".*")
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(alias)
suffix = '__iregex' if valid_regex else '__istartswith'
alias_prefix = '^' if valid_regex else ''
queryset = queryset.filter(
Q(alias__name__iregex="^" + alias)
| Q(alias__normalized_name__iregex="^" + Alias.normalize(alias))
| Q(alias__normalized_name__iregex="^" + alias.lower())
Q(**{f"alias__name{suffix}": alias_prefix + alias})
| Q(**{f"alias__normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
| Q(**{f"alias__normalized_name{suffix}": alias_prefix + alias.lower()})
)
return queryset.order_by("id")
@ -65,7 +69,7 @@ class TrustViewSet(ReadProtectedModelViewSet):
"""
queryset = Trust.objects
serializer_class = TrustSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
'$trusted__alias__name', '$trusted__alias__normalized_name']
filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user']
@ -91,11 +95,11 @@ class AliasViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
then render it on /api/note/aliases/
then render it on /api/note/alias/
"""
queryset = Alias.objects
serializer_class = AliasSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
@ -126,18 +130,22 @@ class AliasViewSet(ReadProtectedModelViewSet):
alias = self.request.query_params.get("alias", None)
if alias:
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(alias)
suffix = '__iregex' if valid_regex else '__istartswith'
alias_prefix = '^' if valid_regex else ''
queryset = queryset.filter(
name__iregex="^" + alias
**{f"name{suffix}": alias_prefix + alias}
).union(
queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
Q(**{f"normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
& ~Q(**{f"name{suffix}": alias_prefix + alias})
),
all=True).union(
queryset.filter(
Q(normalized_name__iregex="^" + alias.lower())
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
Q(**{f"normalized_name{suffix}": "^" + alias.lower()})
& ~Q(**{f"normalized_name{suffix}": "^" + Alias.normalize(alias)})
& ~Q(**{f"name{suffix}": "^" + alias})
),
all=True)
@ -147,7 +155,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
queryset = Alias.objects
serializer_class = ConsumerSerializer
filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
filter_backends = [RegexSafeSearchFilter, OrderingFilter, DjangoFilterBackend]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
@ -166,11 +174,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
alias = self.request.query_params.get("alias", None)
# Check if this is a valid regex. If not, we won't check regex
try:
re.compile(alias)
valid_regex = True
except (re.error, TypeError):
valid_regex = False
valid_regex = is_regex(alias)
suffix = '__iregex' if valid_regex else '__istartswith'
alias_prefix = '^' if valid_regex else ''
queryset = queryset.prefetch_related('note')
@ -179,19 +183,10 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
# We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias.
queryset = queryset.filter(
**{f'name{suffix}': alias_prefix + alias}
).union(
queryset.filter(
Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(**{f'name{suffix}': alias_prefix + alias})
),
all=True).union(
queryset.filter(
Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
& ~Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
& ~Q(**{f'name{suffix}': alias_prefix + alias})
),
all=True)
Q(**{f'name{suffix}': alias_prefix + alias})
| Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
| Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
)
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
else queryset.order_by("name")
@ -207,7 +202,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet):
"""
queryset = TemplateCategory.objects.order_by('name')
serializer_class = TemplateCategorySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'templates', 'templates__name']
search_fields = ['$name', '$templates__name', ]
@ -220,7 +215,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
"""
queryset = TransactionTemplate.objects.order_by('name')
serializer_class = TransactionTemplateSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ]
search_fields = ['$name', '$category__name', ]
ordering_fields = ['amount', ]
@ -234,7 +229,7 @@ class TransactionViewSet(ReadProtectedModelViewSet):
"""
queryset = Transaction.objects.order_by('-created_at')
serializer_class = TransactionPolymorphicSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name',
'destination', 'destination_alias', 'destination__alias__name',
'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount',

View File

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

View File

@ -1,13 +1,14 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from django import forms
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 note_kfet.inputs import Autocomplete, AmountInput, DateTimePickerInput
from note_kfet.inputs import Autocomplete, AmountInput
from .models import TransactionTemplate, NoteClub, Alias

View File

@ -18,6 +18,7 @@ def create_special_notes(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('note', '0001_initial'),
('logs', '0001_initial'),
]
operations = [

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('note', '0006_trust'),
]
operations = [
migrations.AlterField(
model_name='note',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='transaction',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
]

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