227 Commits

Author SHA1 Message Date
Sli
1d03fcf6ea Fix windows path and shell calls issues 2025-01-07 17:10:06 +01:00
Sli
a6ba65a494 Auto download gcc for windows 2025-01-06 23:07:20 +01:00
Sli
c90fcc838e Migrate xapian install script to python 2025-01-06 23:07:20 +01:00
bc9cb9b36c Merge pull request #992 from ae-utbm/uv
Fix install documentation
2025-01-06 22:30:32 +01:00
Sli
edafc06c3f Fix install documentation 2025-01-06 22:26:46 +01:00
134f8a7989 Merge pull request #991 from ae-utbm/uv
Switch from poetry to uv
2025-01-06 22:19:19 +01:00
Sli
771cbdbd77 More explicit uv install steps 2025-01-06 21:59:36 +01:00
Sli
a491baddb9 Apply review comments 2025-01-06 20:13:41 +01:00
Sli
8d10a5e0ab Update deploy scripts to uv 2025-01-06 16:17:56 +01:00
Sli
cbe42d3a60 Add caching for virtualenv 2025-01-06 16:17:56 +01:00
Sli
0c4d72e17a Switch from poetry to uv 2025-01-06 16:17:54 +01:00
429df81ec9 Merge pull request #989 from ae-utbm/counter
Make code matching rank first in counter click
2025-01-05 19:05:10 +01:00
Sli
bb24516474 Make code matching rank first in counter click 2025-01-05 18:54:54 +01:00
8e339c3d4b Merge pull request #988 from ae-utbm/news
fix: wrong link for ae dev discord
2025-01-05 17:29:57 +01:00
Sli
25298518bc fix: wrong link for ae dev discord 2025-01-05 17:25:23 +01:00
2e26ff2cde Merge pull request #986 from ae-utbm/news
Improve welcome page
2025-01-05 17:18:55 +01:00
Sli
a8702d4f5e Improve welcome page
* Improve code readability of calendar details
* Add link to AE Dev discord in useful links
* Add link to github at the bottom
2025-01-05 16:42:26 +01:00
7f4cc5fb0f Merge pull request #980 from ae-utbm/ban-groups
Ban groups
2025-01-05 15:54:19 +01:00
e7215be00e translations 2025-01-05 15:49:30 +01:00
4f35cc00bc Add UserBan management views 2025-01-05 15:49:08 +01:00
af47587116 Split groups and ban groups 2025-01-05 15:49:08 +01:00
3c4daeadb0 Merge pull request #985 from ae-utbm/form-fixes
small form fixes
2025-01-05 15:47:44 +01:00
348ab19ac6 small form fixes
le `display:block` avait disparu des helptext, ce qui rendait leur affichage bizarre. Et il manquait quelques détails sur le `ProductForm`
2025-01-05 15:40:41 +01:00
ada74a3e42 Merge pull request #984 from ae-utbm/lock-poetry
Pin poetry version
2025-01-05 15:02:45 +01:00
785ac9bdab pin poetry version 2025-01-05 14:48:40 +01:00
d1e604e7a5 Merge pull request #975 from ae-utbm/unified-calendar
Unified calendar widget on main com page with external and internal events
2025-01-05 01:46:38 +01:00
Sli
2749a88704 Basic test for internal calendar 2025-01-05 01:36:41 +01:00
Sli
eb3db134f8 Test external calendar caching 2025-01-05 01:32:54 +01:00
Sli
fa7f5d24b0 Test external calendar api 2025-01-05 01:04:11 +01:00
Sli
ba76015c71 Use a newer ical library 2025-01-04 23:12:34 +01:00
Sli
1887a2790f Move IcsCalendar to it's own file 2025-01-04 23:08:09 +01:00
Sli
5d0fc38107 Make social icons links pretty 2025-01-04 23:08:09 +01:00
Sli
65df55a635 Use signals to update internal ics 2025-01-04 23:08:09 +01:00
Sli
a60e1f1fdc Create dedicated class to manage ics calendar files 2025-01-04 23:08:09 +01:00
Sli
0a0f44607e Return calendars as real files 2025-01-04 23:08:09 +01:00
Sli
007080ee48 Extract send_file response creation logic to a dedicated function 2025-01-04 23:08:09 +01:00
Sli
a13e3e95b7 Harmonize titles on front page 2025-01-04 23:08:09 +01:00
Sli
169938e1da Replace old agenda of event with links to services and change permission to see birthdays 2025-01-04 23:08:09 +01:00
Sli
e5fb875968 Add support for event location and more detail link 2025-01-04 22:52:17 +01:00
Sli
9bd14f1b4e Refactor popup creation 2025-01-04 22:51:45 +01:00
Sli
fd2295119d nice looking popup with well aligned icon 2025-01-04 22:51:45 +01:00
Sli
eac2709e86 Create basic (ugly) event detail popup 2025-01-04 22:51:45 +01:00
Sli
48f6d134bf Fix news page layout 2025-01-04 22:51:45 +01:00
Sli
6d7467e746 Make new calendar look like the iframe one 2025-01-04 22:51:44 +01:00
Sli
0d1629495b Refactor com scss and add basic unified event calendar 2025-01-04 22:51:44 +01:00
Sli
63839dc22b Fix poster edition and display bug 2025-01-04 22:51:44 +01:00
c627944bd1 Merge pull request #983 from ae-utbm/gettext
Remove line numbers from locale files
2025-01-04 22:50:10 +01:00
f0be4b270b remove line numbers from locale files 2025-01-04 22:03:37 +01:00
728065e771 Merge pull request #982 from ae-utbm/groups
fix get_or_create in club group migration
2025-01-04 19:01:16 +01:00
849fac490d fix get_or_create in club group migration 2025-01-04 18:49:00 +01:00
5752229312 Merge pull request #981 from ae-utbm/groups
split migrations
2025-01-04 18:14:09 +01:00
6eb860579a split migrations 2025-01-04 18:05:02 +01:00
d08d54b4c9 Merge pull request #935 from ae-utbm/groups
Remove `RealGroup` and `MetaGroup`
2025-01-04 17:13:48 +01:00
bb210f8d47 change club group names when the club name changes 2025-01-04 16:43:38 +01:00
efca10e252 remove Club.view_groups, Club.edit_groups and Club.owner_group 2025-01-03 17:30:24 +01:00
b8f851b009 translations 2025-01-03 01:18:28 +01:00
1e29ae4171 fixes on club group attribution 2025-01-03 01:18:28 +01:00
0ae1e850f4 improve admin 2025-01-03 01:18:28 +01:00
d380668c0f Move users to the club groups in the migration 2025-01-03 01:18:28 +01:00
9a72c5eb72 fix galaxy tests 2025-01-03 01:18:28 +01:00
407cfbe02b update docs 2025-01-03 01:18:28 +01:00
6400b2c2c2 replace MetaGroups by proper group management 2025-01-03 01:18:28 +01:00
cce7ecbe73 Merge pull request #974 from ae-utbm/fix-page
fix 500 error when accessing history of non-existing page
2024-12-29 15:47:38 +01:00
d200c1e381 fix 500 error when accessing history of non-existing page 2024-12-28 13:25:42 +01:00
2f9e5bfee1 Merge pull request #965 from ae-utbm/form-style
rework form style
2024-12-27 22:24:09 +01:00
11702d3d7c Merge pull request #959 from ae-utbm/counter-click-step-4
Make counter click client side first
2024-12-27 22:06:35 +01:00
Sli
43f47e2087 Improve product card display on counter click 2024-12-27 01:59:54 +01:00
4b881903f0 Merge pull request #972 from ae-utbm/fix-product-fetch
Fix product fetch
2024-12-26 23:43:41 +01:00
761e37ade6 fix product fetch 2024-12-26 17:26:06 +01:00
10ed2f7404 Merge pull request #963 from ae-utbm/fix-group-edit
Fix error when submitting group form without any group checked
2024-12-26 17:02:02 +01:00
Sli
43768f1691 Refactor counter-click css 2024-12-26 11:52:30 +01:00
Sli
280d27343d Put error popup inside the basket 2024-12-25 20:44:52 +01:00
Sli
138e1662c7 Add popup css class and display basket error messages with it on counter click 2024-12-24 00:29:23 +01:00
Sli
c80fe094a2 Remove useless form elements in counters and improve alignment 2024-12-23 20:44:49 +01:00
Sli
139221dd22 Apply review comments 2024-12-23 15:15:24 +01:00
72c2981d66 rework form style 2024-12-23 15:11:15 +01:00
Sli
6f003ffa53 Add translations 2024-12-23 02:41:41 +01:00
Sli
7f6fd7dc47 Fix wrong tests/permissions 2024-12-23 02:37:41 +01:00
Sli
ccf5118c9d Add invalid form tests 2024-12-23 02:26:39 +01:00
Sli
022c19c020 Fix counter permissions issues 2024-12-23 02:17:28 +01:00
Sli
2e5e217842 Disable eboutic in counter click/main 2024-12-23 01:35:44 +01:00
Sli
9c93c004ec Add more counter click tests 2024-12-23 01:18:01 +01:00
Sli
472800eff6 Add nice snackbar message on counter interface and fix not enough money protection on frontend 2024-12-23 00:56:57 +01:00
Sli
b8d43a629b Increase selling label size and add more counter click tests 2024-12-23 00:00:40 +01:00
Sli
f6693e12cf Basic counter click tests 2024-12-22 19:24:07 +01:00
Sli
38f491cf57 Properly test annotations in counter click 2024-12-22 16:43:07 +01:00
Sli
3464d5d860 Add proper tests for refilling view 2024-12-22 16:16:28 +01:00
81773dc800 Merge pull request #964 from ae-utbm/fix-backend
Fix custom auth backend
2024-12-22 15:07:46 +01:00
da400155eb fix SithModelBackend._get_group_permissions 2024-12-22 15:01:58 +01:00
Sli
5079938a5b Fix get_operator on non bar counters and better display of counter with no products 2024-12-22 13:36:50 +01:00
Sli
b8430adc50 Split counter-click-index.ts 2024-12-22 13:01:37 +01:00
Sli
eed434aeb2 Improve age management for getting products and make get_product a part of counter model 2024-12-22 12:27:58 +01:00
Sli
372470b44b Improve empty basket and tray price management 2024-12-22 12:06:15 +01:00
Sli
7071553c3b Optimize product id validation on counter click 2024-12-22 12:06:15 +01:00
Sli
eea237b813 Pre-filter allowed products in backend for counter click 2024-12-22 12:06:15 +01:00
Sli
c37288c285 Display nice product cards on counter click interface 2024-12-22 12:06:15 +01:00
Sli
ccf5767a01 Fix customerBalance not init and submit/cancel buttons visuals 2024-12-22 12:06:15 +01:00
Sli
ffe6fc8c2a Redirect when cancelling instead of submitting a form 2024-12-22 12:06:15 +01:00
Sli
5f0b4d2050 Properly display form errors in counter 2024-12-22 12:06:15 +01:00
Sli
f9d7dc7d3a Restore form when form submit fails due to error 2024-12-22 12:06:15 +01:00
Sli
8ebea00896 Fix crash during validation 2024-12-22 12:06:15 +01:00
Sli
a548f4744e Fix counter main
* Fix crash when submitting nothing
* Fix code field not being autofocus
2024-12-22 12:06:15 +01:00
Sli
a383f3e717 Don't use codes as a primary key in counter click 2024-12-22 12:06:15 +01:00
Sli
60f18669c8 Make counter click client side first 2024-12-22 12:06:14 +01:00
Sli
a36946529b Fix error when submitting group form without any group checked 2024-12-22 12:04:51 +01:00
eaac0c728f Merge pull request #961 from ae-utbm/auth-backend
Custom auth backend
2024-12-22 06:38:34 +01:00
9ca95774a3 Merge pull request #962 from ae-utbm/query-news
Fix N+1 queries on birthdays
2024-12-22 06:32:58 +01:00
fa66851889 fix n+1 queries on birthdays 2024-12-21 21:09:08 +01:00
ab81f11199 Manage subscribers group permissions 2024-12-21 18:52:16 +01:00
bea7741d35 populate group permissions 2024-12-21 18:48:30 +01:00
81e163812e custom auth backend 2024-12-21 17:34:20 +01:00
4f233538e0 Merge pull request #955 from ae-utbm/counter-click-step-3
Use TomSelect for product selection on counter
2024-12-21 16:00:06 +01:00
Sli
4ac09ac08b Use tomselect instead of jquery autoselect for counter clicks 2024-12-21 15:56:18 +01:00
6d02970676 Merge pull request #946 from ae-utbm/product-csv
Rework the product admin page
2024-12-21 15:50:34 +01:00
accf1befce Make products filterable by product type 2024-12-21 02:15:51 +01:00
6953eaa9d0 fix sanitization of the csv content 2024-12-21 02:14:38 +01:00
180bae59c8 Add translations 2024-12-21 02:14:38 +01:00
9cafc163e8 fix frontend archived products filter 2024-12-21 02:14:38 +01:00
8f8eef4107 display products as cards 2024-12-21 02:14:38 +01:00
7af745087e create a card css component 2024-12-21 02:14:38 +01:00
aab093200b slightly improve style 2024-12-21 02:14:38 +01:00
1a9556f811 add a button to download products as csv 2024-12-21 02:14:38 +01:00
39b36aa509 ajaxify the product admin page 2024-12-21 02:14:38 +01:00
3fc260a12c add csv converter 2024-12-21 02:14:38 +01:00
1696a2f579 Add NestedKeyOf Type 2024-12-21 02:14:38 +01:00
baebc0b690 Merge pull request #958 from ae-utbm/fix-group-form
fix user groups form
2024-12-20 11:07:13 +01:00
9f3a10ca71 fix user groups form 2024-12-20 11:00:57 +01:00
38ceaf3106 Merge pull request #957 from ae-utbm/user-model
Fix groups displayed on user profile group edition
2024-12-19 20:32:39 +01:00
Sli
87b619794d Fix groups displayed on user profile group edition 2024-12-19 18:57:50 +01:00
29c4a36479 Merge pull request #956 from ae-utbm/query-page-hist
Fix N+1 queries on page history
2024-12-19 15:09:11 +01:00
ddeb12f08c Merge pull request #929 from ae-utbm/user-model
Migrate User parent class from AbstractBaseUser to AbstractUser
2024-12-19 14:27:16 +01:00
a7b1406e06 post-rebase fix 2024-12-19 10:53:11 +01:00
871ef60cf6 remove obsolete RunPython operations 2024-12-19 10:39:07 +01:00
7e9071a533 optimize User.is_subscribed and User.was_subscribed 2024-12-19 10:39:07 +01:00
8c660e9856 Make core.User inherit from AbstractUser instead of AbstractBaseUser 2024-12-19 10:39:04 +01:00
6ca641ab7f fix: N+1 queries on page version list page 2024-12-19 10:32:02 +01:00
8d6609566f Merge pull request #951 from ae-utbm/refactor-news
refactor news model and creation form
2024-12-18 16:09:41 +01:00
17e4c63737 refactor news model and creation form 2024-12-18 15:54:10 +01:00
fad470b670 Merge pull request #952 from ae-utbm/sort-producttypes
Sort product types
2024-12-18 15:45:50 +01:00
c5646b1e59 Merge pull request #954 from ae-utbm/fix-subscription
fix access to the subscription page
2024-12-18 15:45:17 +01:00
5da27bb266 rename producttype to product_type 2024-12-18 14:48:59 +01:00
be6a077c8e fix access to the subscription page 2024-12-18 14:13:39 +01:00
8d643fc6b4 Apply review comments 2024-12-17 17:23:13 +01:00
47876e3971 Make product types dynamically orderable. 2024-12-17 13:35:29 +01:00
c79c251ba7 Add ProductTypeController 2024-12-17 13:35:29 +01:00
483670e798 Make ProductType an OrderedModel 2024-12-17 13:35:29 +01:00
6c8a6008d5 api route to search products with detailed infos. 2024-12-17 12:38:59 +01:00
e680124d7b fix makemessages command in docs 2024-12-17 12:38:59 +01:00
b06a06f50c feat: add restore on backspace plugin for tom select 2024-12-17 12:38:59 +01:00
6416de237f Merge pull request #923 from ae-utbm/counter-click-step-2
Casser counter click step 2 : separate refilling from counter clicks with fragments
2024-12-17 10:58:34 +01:00
Sli
ad44fd52a4 Apply review comments 2024-12-17 10:54:41 +01:00
Sli
03c27b10e5 Fix refill permissions
* Remove ability to refill from counters
* Fix bug where you could refill without any board member on a BAR
* Add a warning message explaining why refilling are disabled
2024-12-17 02:42:07 +01:00
Sli
fc0ef29738 Remove GetCustomer API endpoint 2024-12-17 01:42:10 +01:00
Sli
a0eb53a607 Apply review comments 2024-12-17 01:41:45 +01:00
Sli
66e5ef64fd Don't use API to update amount after a refilling query 2024-12-17 00:47:43 +01:00
f5d5cc18a8 Merge pull request #949 from ae-utbm/trombi
Fix crash when admin gets to preferences of an user subscribed to a trombinoscope
2024-12-16 10:06:17 +01:00
Sli
4c65939bbe Fix crash when admin gets to preferences of an user subscribed to a trombinoscope 2024-12-16 09:31:43 +01:00
Sli
379527cd58 Add a nice animation on successful refilling 2024-12-16 00:58:23 +01:00
Sli
f63fb59cbf Allow filtering of refilling options
* Move settings.SITH_COUNTER_PAYMENT_METHOD to counter.apps.PAYMENT_METHOD
* Move student cards to an accordion on counter click
* Make cash default refilling option
* Disable bank selection option in refilling if CHECK are not allowed
* Disable refilling with CHECK from the frontend
2024-12-16 00:15:21 +01:00
Sli
cde864fdc7 Apply review comments 2024-12-15 22:47:59 +01:00
Sli
e9361697f7 Convert customer refill to a fragment view 2024-12-15 21:33:19 +01:00
830c752971 Merge pull request #948 from ae-utbm/sentry
Enable sentry workflow again
2024-12-15 18:36:46 +01:00
Sli
6bdc1b73ae Enable sentry workflow again 2024-12-15 17:31:41 +01:00
0f003870bb Merge pull request #924 from ae-utbm/unique-student-card
Make student card unique per user
2024-12-15 17:06:35 +01:00
Sli
0631c77a1c Apply review comments 2024-12-15 17:02:44 +01:00
Sli
2cc4308a58 Fix tooltip shadow and position and improve unittests 2024-12-15 16:49:24 +01:00
Sli
4975475e85 Add tooltip on current registered card, allow barmen to delete cards and make card deletion a fragment 2024-12-15 16:49:24 +01:00
466fe58763 feat: make student card unique per user 2024-12-15 16:49:24 +01:00
3b7e338808 fix 500 when accessing preferences
Quand on tente d'accéder aux préférences d'un utilisateur relié à un trombi, sans être soi-même dans un trombi, on a une erreur.
2024-12-15 16:49:24 +01:00
53b13e7aef Merge pull request #947 from ae-utbm/dependencies
Upgrade dependencies
2024-12-15 13:53:28 +01:00
Sli
fa60ecb25a Upgrade dependencies 2024-12-15 00:59:55 +01:00
a975824481 Merge pull request #945 from ae-utbm/refactor-product
Remove `Product.parent_product`
2024-12-09 20:20:11 +01:00
c51e5eb6cb remove parent_product column in the Product table 2024-12-09 12:59:33 +01:00
f0bc502ec9 fix translation in subscription creation success fragment 2024-12-09 12:31:58 +01:00
902cafc5e4 Merge pull request #921 from ae-utbm/counter-click
Casser counter click étape 1 : introduire des fragments
2024-12-08 13:49:08 +01:00
b2f54aa23e Merge pull request #943 from ae-utbm/update-deps
Update deps
2024-12-08 13:46:53 +01:00
Sli
29a5425259 Add spinner to student card form 2024-12-08 13:17:56 +01:00
e2a34c75ea deps: update dependencies 2024-12-08 11:54:58 +01:00
Sli
de7aa6f6a6 Create a generic form fragment renderer 2024-12-08 11:45:16 +01:00
9acb421b2e deps: update ruff 2024-12-08 11:17:27 +01:00
Sli
66d2dc74e7 Pre-fetch forms for student card 2024-12-08 00:32:28 +01:00
Sli
2f613607af Update number of queries in test_num_queries 2024-12-07 23:35:35 +01:00
Sli
d4b9c3afb1 Make StudentCardFormView fragment only 2024-12-07 22:36:15 +01:00
Sli
b81cf49d0a Remove student card creation from CounterClick view and use fragment instead
Intercept htmx on submit requests, this allows auto submit from nfc fields

Fix super call with parameters

Add loading wheel on student card form for counter_click.jinja
2024-12-07 12:57:10 +01:00
1da45fdffc Merge pull request #934 from ae-utbm/split-counter
Split counter views into multiple files
2024-12-07 11:53:14 +01:00
10dde3f002 fix imports 2024-12-07 00:18:17 +01:00
c2d6af12ab Merge branches 'split-home' and 'split-studentcard' into split-counter 2024-12-07 00:13:50 +01:00
6e48f88c06 extract counter auth views 2024-12-07 00:12:10 +01:00
7a91a71565 extract counter auth views 2024-12-07 00:11:18 +01:00
c4764110d8 extract counter home views 2024-12-07 00:10:46 +01:00
ff68e65250 extract counter home views 2024-12-07 00:07:37 +01:00
c9d83e5916 extract student card views 2024-12-07 00:06:33 +01:00
5dc99dbfcb extract student card views 2024-12-07 00:05:45 +01:00
8dbec85c8e Merge pull request #941 from ae-utbm/optimize-search
Optimize search
2024-12-06 21:00:06 +01:00
84d7e40e66 feat: client-side cache for ajax-select inputs 2024-12-06 18:38:30 +01:00
0b509f2200 fix N+1 queries on user search 2024-12-06 18:38:30 +01:00
9591162cc9 Merge pull request #940 from ae-utbm/fix-dump
Fix the account dump command.
2024-12-05 19:52:07 +01:00
007e17fd8b Fix the account dump command.
- a missing `fail_silently` flag made the whole command fail if an invalid recipient is used (like closed utbm mail address)
- Not specifying the seller make the account detail pages crash.
2024-12-05 12:50:40 +01:00
95f8e7517c Merge pull request #932 from ae-utbm/fix-subscriptions
Rework the subscription page
2024-12-03 19:45:26 +01:00
9667c79162 remove htmx-ext-response-targets 2024-12-03 19:41:10 +01:00
1c79c25262 better tab style 2024-12-03 19:41:09 +01:00
04b4b34bfe add back user profiles on subscription form 2024-12-03 19:41:09 +01:00
fc0e689d4e add initial values to forms 2024-12-03 19:41:09 +01:00
83bb4b3b12 add translation 2024-12-03 19:41:09 +01:00
8dcfc604a0 write tests 2024-12-03 19:41:09 +01:00
d2d639e5f6 Split SubscriptionForm into SubscriptionNewUserForm and SubscriptionExistingUserForm 2024-12-03 19:41:09 +01:00
b3eb7693e3 Merge pull request #933 from ae-utbm/remove-stock
delete stock application
2024-11-28 23:20:35 +01:00
10f42b1522 fix imports 2024-11-27 19:03:34 +01:00
76e9f3b1dc Merge branches 'split-cash', 'split-click', 'split-main', 'split-admin', 'split-mixins', 'split-eticket' and 'split-invoices' into split-clean 2024-11-27 18:49:40 +01:00
d0ff9bc16c extract mixins views 2024-11-27 18:48:06 +01:00
5e4ebd16f9 extract mixins views 2024-11-27 18:47:55 +01:00
d2b19424ff extract eticket views 2024-11-27 18:47:18 +01:00
08286254cd extract eticket views 2024-11-27 18:47:03 +01:00
4805c39b45 extract cash views 2024-11-27 18:46:24 +01:00
f845bbf20a extract cash views 2024-11-27 18:45:27 +01:00
71c7158124 extract invoice views 2024-11-27 18:43:26 +01:00
c4643ee52c extract invoice views 2024-11-27 18:42:50 +01:00
b46b0882f3 extract admin views 2024-11-27 18:42:26 +01:00
1c4efc9431 extract admin views 2024-11-27 18:41:47 +01:00
4133e0ccdd extract click views 2024-11-27 18:41:12 +01:00
de415e7e75 split click views 2024-11-27 18:40:38 +01:00
9d17524f45 extract main views 2024-11-27 18:00:48 +01:00
68ad9650af extract main views 2024-11-27 17:56:44 +01:00
8d4d8a3abc create views package 2024-11-27 17:07:08 +01:00
9617e29ed5 delete stock application 2024-11-26 17:35:10 +01:00
75406f7b58 Tabs jinja component 2024-11-26 16:17:44 +01:00
70f5ae4f9c Move subscription forms to subscription/forms.py 2024-11-26 16:17:44 +01:00
200 changed files with 13915 additions and 10633 deletions

14
.envrc
View File

@ -1,14 +1,6 @@
if [[ ! -f pyproject.toml ]]; then
log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
if [[ ! -d .venv ]]; then
log_error 'No .venv folder found. Use `uv sync` to create one first.'
exit 2
fi
local VENV=$(poetry env list --full-path | cut -d' ' -f1)
if [[ -z $VENV || ! -d $VENV/bin ]]; then
log_error 'No poetry virtual environment found. Use `poetry install` to create one first.'
exit 2
fi
export VIRTUAL_ENV=$VENV
export POETRY_ACTIVE=1
PATH_add "$VENV/bin"
. .venv/bin/activate

View File

@ -1,8 +0,0 @@
name: "Compile messages"
description: "Compile the gettext translation messages"
runs:
using: composite
steps:
- name: Setup project
run: poetry run ./manage.py compilemessages
shell: bash

View File

@ -9,43 +9,38 @@ runs:
packages: gettext
version: 1.0 # increment to reset cache
- name: Set up python
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version-file: ".python-version"
- name: Load cached Poetry installation
id: cached-poetry
uses: actions/cache@v3
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
with:
path: ~/.local
key: poetry-3 # increment to reset cache
- name: Install Poetry
if: steps.cached-poetry.outputs.cache-hit != 'true'
shell: bash
run: curl -sSL https://install.python-poetry.org | python3 -
- name: Check pyproject.toml syntax
shell: bash
run: poetry check
- name: Load cached dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-poetry-
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Install dependencies
run: poetry install --with docs,tests
run: uv sync
shell: bash
- name: Install xapian
run: poetry run ./manage.py install_xapian
- name: Install Xapian
run: uv run ./manage.py install_xapian
shell: bash
- name: Save cached virtualenv
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
run: poetry run ./manage.py compilemessages
run: uv run ./manage.py compilemessages
shell: bash

View File

@ -1,10 +0,0 @@
name: "Setup xapian"
description: "Setup the xapian indexes"
runs:
using: composite
steps:
- name: Setup xapian index
run: |
mkdir -p /dev/shm/search_indexes
ln -s /dev/shm/search_indexes sith/search_indexes
shell: bash

View File

@ -14,6 +14,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1
with:
extra_args: --all-files
@ -29,14 +31,15 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4
- uses: ./.github/actions/setup_project
- uses: ./.github/actions/setup_xapian
- uses: ./.github/actions/compile_messages
env:
# To avoid race conditions on environment cache
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
- name: Run tests
run: poetry run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
- name: Generate coverage report
run: |
poetry run coverage report
poetry run coverage html
uv run coverage report
uv run coverage html
- name: Archive code coverage results
uses: actions/upload-artifact@v3
with:

View File

@ -37,11 +37,29 @@ jobs:
git fetch
git reset --hard origin/master
poetry install --with prod --without docs,tests
uv sync --group prod
npm install
poetry run ./manage.py install_xapian
poetry run ./manage.py migrate
poetry run ./manage.py collectstatic --clear --noinput
poetry run ./manage.py compilemessages
uv run ./manage.py install_xapian
uv run ./manage.py migrate
uv run ./manage.py collectstatic --clear --noinput
uv run ./manage.py compilemessages
sudo systemctl restart uwsgi
sentry:
runs-on: ubuntu-latest
environment: production
timeout-minutes: 30
needs: deployment
steps:
- uses: actions/checkout@v4
- name: Sentry Release
uses: getsentry/action-release@v1.7.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
with:
environment: production

View File

@ -18,4 +18,4 @@ jobs:
path: .cache
restore-keys: |
mkdocs-material-
- run: poetry run mkdocs gh-deploy --force
- run: uv run mkdocs gh-deploy --force

View File

@ -36,11 +36,11 @@ jobs:
git fetch
git reset --hard origin/taiste
poetry install --with prod --without docs,tests
uv sync --group prod
npm install
poetry run ./manage.py install_xapian
poetry run ./manage.py migrate
poetry run ./manage.py collectstatic --clear --noinput
poetry run ./manage.py compilemessages
uv run ./manage.py install_xapian
uv run ./manage.py migrate
uv run ./manage.py collectstatic --clear --noinput
uv run ./manage.py compilemessages
sudo systemctl restart uwsgi

2
.gitignore vendored
View File

@ -8,7 +8,7 @@ pyrightconfig.json
dist/
.vscode/
.idea/
env/
.venv/
doc/html
data/
galaxy/test_galaxy_state.json

View File

@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.6.9
rev: v0.8.3
hooks:
- id: ruff # just check the code, and print the errors
- id: ruff # actually fix the fixable errors, but print nothing
@ -14,7 +14,7 @@ repos:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.3"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.6
rev: 3.0.7
hooks:
- id: djhtml
name: format templates

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.12

View File

@ -216,7 +216,7 @@ class TestOperation(TestCase):
self.journal.operations.filter(target_label="Le fantome du jour").exists()
)
def test__operation_simple_accounting(self):
def test_operation_simple_accounting(self):
sat = SimplifiedAccountingType.objects.all().first()
response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]),
@ -237,15 +237,14 @@ class TestOperation(TestCase):
"done": False,
},
)
self.assertFalse(response.status_code == 403)
self.assertTrue(self.journal.operations.filter(amount=23).exists())
assert response.status_code != 403
assert self.journal.operations.filter(amount=23).exists()
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
self.assertTrue(
"<td>Le fantome de l&#39;aurore</td>" in str(response_get.content)
)
self.assertTrue(
assert "<td>Le fantome de l&#39;aurore</td>" in str(response_get.content)
assert (
self.journal.operations.filter(amount=23)
.values("accounting_type")
.first()["accounting_type"]

View File

@ -215,17 +215,14 @@ class JournalTabsMixin(TabedViewMixin):
return _("Journal")
def get_list_of_tabs(self):
tab_list = []
tab_list.append(
return [
{
"url": reverse(
"accounting:journal_details", kwargs={"j_id": self.object.id}
),
"slug": "journal",
"name": _("Journal"),
}
)
tab_list.append(
},
{
"url": reverse(
"accounting:journal_nature_statement",
@ -233,9 +230,7 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "nature_statement",
"name": _("Statement by nature"),
}
)
tab_list.append(
},
{
"url": reverse(
"accounting:journal_person_statement",
@ -243,9 +238,7 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "person_statement",
"name": _("Statement by person"),
}
)
tab_list.append(
},
{
"url": reverse(
"accounting:journal_accounting_statement",
@ -253,9 +246,8 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "accounting_statement",
"name": _("Accounting statement"),
}
)
return tab_list
},
]
class JournalCreateView(CanCreateMixin, CreateView):

View File

@ -20,6 +20,14 @@ from club.models import Club, Membership
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
list_display = ("name", "unix_name", "parent", "is_active")
search_fields = ("name", "unix_name")
autocomplete_fields = (
"parent",
"board_group",
"members_group",
"home",
"page",
)
@admin.register(Membership)

View File

@ -3,19 +3,6 @@ from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
from club.models import Club
from core.operations import PsqlRunOnly
def generate_club_pages(apps, schema_editor):
def recursive_generate_club_page(club):
club.make_page()
for child in Club.objects.filter(parent=club).all():
recursive_generate_club_page(child)
for club in Club.objects.filter(parent=None).all():
recursive_generate_club_page(club)
class Migration(migrations.Migration):
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
@ -48,11 +35,4 @@ class Migration(migrations.Migration):
null=True,
),
),
PsqlRunOnly(
"SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
),
migrations.RunPython(generate_club_pages),
PsqlRunOnly(
migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
),
]

View File

@ -0,0 +1,106 @@
# Generated by Django 4.2.16 on 2024-11-20 17:08
import django.db.models.deletion
import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs.
Until now, the meta groups were not attached to the clubs,
nor to the users.
This creates actual foreign relationships between the clubs
and theirs groups and the users and theirs groups.
Warnings:
When the meta groups associated with the clubs aren't found,
they are created.
Thus the migration shouldn't fail, and all the clubs will
have their groups.
However, there will probably be some groups that have
not been found but exist nonetheless,
so there will be duplicates and dangling groups.
There must be a manual cleanup after this migration.
"""
Group = apps.get_model("core", "Group")
Club = apps.get_model("club", "Club")
meta_groups = Group.objects.filter(is_meta=True)
clubs = list(Club.objects.all())
for club in clubs:
club.board_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_BOARD_SUFFIX,
defaults={"is_meta": True},
)[0]
club.members_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
defaults={"is_meta": True},
)[0]
club.save()
club.refresh_from_db()
memberships = club.members.filter(
Q(end_date=None) | Q(end_date__gt=localdate())
).select_related("user")
club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set(
[
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
)
# steps of the migration :
# - Create a nullable field for the board group and the member group
# - Edit those new fields to make them point to currently existing meta groups
# - When this data migration is done, make the fields non-nullable
class Migration(migrations.Migration):
dependencies = [
("core", "0040_alter_user_options_user_user_permissions_and_more"),
("club", "0011_auto_20180426_2013"),
]
operations = [
migrations.RemoveField(
model_name="club",
name="edit_groups",
),
migrations.RemoveField(
model_name="club",
name="owner_group",
),
migrations.RemoveField(
model_name="club",
name="view_groups",
),
migrations.AddField(
model_name="club",
name="board_group",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AddField(
model_name="club",
name="members_group",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.RunPython(
migrate_meta_groups, reverse_code=migrations.RunPython.noop, elidable=True
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.17 on 2025-01-04 16:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0012_club_board_group_club_members_group")]
operations = [
migrations.AlterField(
model_name="club",
name="board_group",
field=models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AlterField(
model_name="club",
name="members_group",
field=models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.AddConstraint(
model_name="membership",
constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))),
name="end_after_start",
),
),
]

View File

@ -23,7 +23,7 @@
#
from __future__ import annotations
from typing import Self
from typing import Iterable, Self
from django.conf import settings
from django.core import validators
@ -31,14 +31,14 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
from django.db.models import Q
from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
from core.models import Group, Notification, Page, SithFile, User
# Create your models here.
@ -79,19 +79,6 @@ class Club(models.Model):
_("short description"), max_length=1000, default="", blank=True, null=True
)
address = models.CharField(_("address"), max_length=254)
owner_group = models.ForeignKey(
Group,
related_name="owned_club",
default=get_default_owner_group,
on_delete=models.CASCADE,
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_club", blank=True
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_club", blank=True
)
home = models.OneToOneField(
SithFile,
related_name="home_of_club",
@ -103,6 +90,12 @@ class Club(models.Model):
page = models.OneToOneField(
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
)
members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT
)
board_group = models.OneToOneField(
Group, related_name="club_board", on_delete=models.PROTECT
)
class Meta:
ordering = ["name", "unix_name"]
@ -112,23 +105,27 @@ class Club(models.Model):
@transaction.atomic()
def save(self, *args, **kwargs):
old = Club.objects.filter(id=self.id).first()
creation = old is None
if not creation and old.unix_name != self.unix_name:
self._change_unixname(self.unix_name)
creation = self._state.adding
if not creation:
db_club = Club.objects.get(id=self.id)
if self.unix_name != db_club.unix_name:
self.home.name = self.unix_name
self.home.save()
if self.name != db_club.name:
self.board_group.name = f"{self.name} - Bureau"
self.board_group.save()
self.members_group.name = f"{self.name} - Membres"
self.members_group.save()
if creation:
self.board_group = Group.objects.create(
name=f"{self.name} - Bureau", is_manually_manageable=False
)
self.members_group = Group.objects.create(
name=f"{self.name} - Membres", is_manually_manageable=False
)
super().save(*args, **kwargs)
if creation:
board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX)
board.save()
member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX)
member.save()
subscribers = Group.objects.filter(
name=settings.SITH_MAIN_MEMBERS_GROUP
).first()
self.make_home()
self.home.edit_groups.set([board])
self.home.view_groups.set([member, subscribers])
self.home.save()
self.make_page()
cache.set(f"sith_club_{self.unix_name}", self)
@ -136,7 +133,8 @@ class Club(models.Model):
return reverse("club:club_view", kwargs={"club_id": self.id})
@cached_property
def president(self):
def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club."""
return self.members.filter(
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first()
@ -154,27 +152,9 @@ class Club(models.Model):
def clean(self):
self.check_loop()
def _change_unixname(self, old_name, new_name):
c = Club.objects.filter(unix_name=new_name).first()
if c is None:
# Update all the groups names
Group.objects.filter(name=old_name).update(name=new_name)
Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
name=new_name + settings.SITH_BOARD_SUFFIX
)
Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
name=new_name + settings.SITH_MEMBER_SUFFIX
)
def make_home(self) -> None:
if self.home:
self.home.name = new_name
self.home.save()
else:
raise ValidationError(_("A club with that unix_name already exists"))
def make_home(self):
if not self.home:
return
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
root = User.objects.filter(username="root").first()
if home_root and root:
@ -183,7 +163,7 @@ class Club(models.Model):
self.home = home
self.save()
def make_page(self):
def make_page(self) -> None:
root = User.objects.filter(username="root").first()
if not self.page:
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
@ -213,35 +193,34 @@ class Club(models.Model):
self.page.parent = self.parent.page
self.page.save(force_lock=True)
def delete(self, *args, **kwargs):
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}")
cache.delete(f"sith_club_{self.unix_name}")
super().delete(*args, **kwargs)
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
def get_display_name(self):
def get_display_name(self) -> str:
return self.name
def is_owned_by(self, user):
def is_owned_by(self, user: User) -> bool:
"""Method to see if that object can be super edited by the given user."""
if user.is_anonymous:
return False
return user.is_board_member
return user.is_root or user.is_board_member
def get_full_logo_url(self):
return "https://%s%s" % (settings.SITH_URL, self.logo.url)
def get_full_logo_url(self) -> str:
return f"https://{settings.SITH_URL}{self.logo.url}"
def can_be_edited_by(self, user):
def can_be_edited_by(self, user: User) -> bool:
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def can_be_viewed_by(self, user):
def can_be_viewed_by(self, user: User) -> bool:
"""Method to see if that object can be seen by the given user."""
sub = User.objects.filter(pk=user.pk).first()
if sub is None:
return False
return sub.was_subscribed
return user.was_subscribed
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user.
@ -262,9 +241,8 @@ class Club(models.Model):
cache.set(f"membership_{self.id}_{user.id}", membership)
return membership
def has_rights_in_club(self, user):
m = self.get_membership_for(user)
return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
def has_rights_in_club(self, user: User) -> bool:
return user.is_in_group(pk=self.board_group_id)
class MembershipQuerySet(models.QuerySet):
@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet):
"""
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def update(self, **kwargs):
"""Refresh the cache for the elements of the queryset.
def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership.
Besides that, does the same job as a regular update method.
Update the cache, when necessary, remove
users from club groups they are no more in
and add them in the club groups they should be in.
Be aware that this adds a db query to retrieve the updated objects
Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
"""
nb_rows = super().update(**kwargs)
if nb_rows > 0:
# if at least a row was affected, refresh the cache
for membership in self.all():
if membership.end_date is not None:
cache.set(
f"membership_{membership.club_id}_{membership.user_id}",
"not_member",
)
else:
cache.set(
f"membership_{membership.club_id}_{membership.user_id}",
membership,
)
if nb_rows == 0:
# if no row was affected, no need to refresh the cache
return 0
def delete(self):
cache_memberships = {}
memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones
# It's more concise to write and more reliable
Membership._remove_club_groups(memberships)
Membership._add_club_groups(memberships)
for member in memberships:
cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else:
cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships)
return nb_rows
def delete(self) -> tuple[int, dict[str, int]]:
"""Work just like the default Django's delete() method,
but add a cache invalidation for the elements of the queryset
before the deletion.
before the deletion,
and a removal of the user from the club groups.
Be aware that this adds a db query to retrieve the deleted element.
As this first query take place before the deletion operation,
it will be performed even if the deletion fails.
Be aware that this adds some db queries :
- 1 to retrieve the deleted elements in order to perform
post-delete operations.
As we can't know if a delete will affect rows or not,
this query will always happen
- 1 query to remove the users from the club groups.
If the delete operation affected no row,
this query won't happen.
"""
ids = list(self.values_list("club_id", "user_id"))
nb_rows, _ = super().delete()
memberships = set(self.all())
nb_rows, rows_counts = super().delete()
if nb_rows > 0:
for club_id, user_id in ids:
cache.set(f"membership_{club_id}_{user_id}", "not_member")
Membership._remove_club_groups(memberships)
cache.set_many(
{
f"membership_{m.club_id}_{m.user_id}": "not_member"
for m in memberships
}
)
return nb_rows, rows_counts
class Membership(models.Model):
@ -361,6 +362,13 @@ class Membership(models.Model):
objects = MembershipQuerySet.as_manager()
class Meta:
constraints = [
models.CheckConstraint(
check=Q(end_date__gte=F("start_date")), name="end_after_start"
),
]
def __str__(self):
return (
f"{self.club.name} - {self.user.username} "
@ -370,7 +378,14 @@ class Membership(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# a save may either be an update or a creation
# and may result in either an ongoing or an ended membership.
# It could also be a retrogradation from the board to being a simple member.
# To avoid problems, the user is removed from the club groups beforehand ;
# he will be added back if necessary
self._remove_club_groups([self])
if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
@ -378,11 +393,11 @@ class Membership(models.Model):
def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id})
def is_owned_by(self, user):
def is_owned_by(self, user: User) -> bool:
"""Method to see if that object can be super edited by the given user."""
if user.is_anonymous:
return False
return user.is_board_member
return user.is_root or user.is_board_member
def can_be_edited_by(self, user: User) -> bool:
"""Check if that object can be edited by the given user."""
@ -392,9 +407,91 @@ class Membership(models.Model):
return membership is not None and membership.role >= self.role
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(
memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club groups.
For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll.
After calling this function, he will be in neither.
Returns:
The result of the deletion queryset.
Warnings:
If this function isn't used in combination
with an actual deletion of the memberships,
it will result in an inconsistent state,
where users will be in the clubs, without
having the associated rights.
"""
clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
return User.groups.through.objects.filter(
Q(group__in=groups) & Q(user__in=users)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> list[User.groups.through]:
"""Add users of those memberships to the club groups.
For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group
of the club.
Returns:
The created User-Group relations.
Warnings:
If this function isn't used in combination
with an actual update/creation of the memberships,
it will result in an inconsistent state,
where users will have the rights associated to the
club, without actually being part of it.
"""
# only active membership (i.e. `end_date=None`)
# grant the attribution of club groups.
memberships = [m for m in memberships if m.end_date is None]
if not memberships:
return []
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
# if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with
# a select_related than perform a distinct query
# to fetch each club.
ids = {m.id for m in memberships}
memberships = list(
Membership.objects.filter(id__in=ids).select_related("club")
)
club_groups = []
for membership in memberships:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.board_group_id,
)
)
return User.groups.through.objects.bulk_create(
club_groups, ignore_conflicts=True
)
class Mailing(models.Model):
"""A Mailing list for a club.
@ -438,14 +535,13 @@ class Mailing(models.Model):
def save(self, *args, **kwargs):
if not self.is_moderated:
for user in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
if not user.notifications.filter(
type="MAILING_MODERATION", viewed=False
).exists():
Notification(
user=user,
url=reverse("com:mailing_admin"),

View File

@ -21,6 +21,7 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import localdate, localtime, now
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, Mailing, Membership
@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
membership = baker.make(Membership, end_date=None, user=user, role=5)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=1) # from board to simple member
assert user.groups.contains(members_group)
assert not user.groups.contains(board_group)
user.memberships.update(role=5) # from member to board
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(end_date=localdate()) # end the membership
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.skia.memberships.get(club=self.club)
@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
)
assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
memberships[1].club.board_group,
}
assert set(user.groups.all()) == club_groups
user.memberships.all().delete()
assert user.groups.all().count() == 0
class TestClubModel(TestClub):
def assert_membership_started_today(self, user: User, role: int):
@ -192,10 +227,8 @@ class TestClubModel(TestClub):
assert membership.end_date is None
assert membership.role == role
assert membership.club.get_membership_for(user) == membership
member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX
assert user.is_in_group(name=member_group)
assert user.is_in_group(name=board_group)
assert user.is_in_group(pk=self.club.members_group_id)
assert user.is_in_group(pk=self.club.board_group_id)
def assert_membership_ended_today(self, user: User):
"""Assert that the given user have a membership which ended today."""
@ -474,37 +507,35 @@ class TestClubModel(TestClub):
assert self.club.members.count() == nb_memberships
assert membership == new_mem
def test_delete_remove_from_meta_group(self):
"""Test that when a club is deleted, all its members are removed from the
associated metagroup.
"""
memberships = self.club.members.select_related("user")
users = [membership.user for membership in memberships]
meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User)
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
assert user.groups.contains(self.club.members_group)
assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate())
assert not user.groups.contains(self.club.members_group)
assert not user.groups.contains(self.club.board_group)
self.club.delete()
for user in users:
assert not user.is_in_group(name=meta_group)
def test_add_to_club_group(self):
"""Test that when a membership begins, the user is added to the club group."""
assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
def test_add_to_meta_group(self):
"""Test that when a membership begins, the user is added to the meta group."""
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
assert not self.subscriber.is_in_group(name=group_members)
assert not self.subscriber.is_in_group(name=board_members)
Membership.objects.create(club=self.club, user=self.subscriber, role=3)
assert self.subscriber.is_in_group(name=group_members)
assert self.subscriber.is_in_group(name=board_members)
def test_remove_from_meta_group(self):
"""Test that when a membership ends, the user is removed from meta group."""
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
assert self.comptable.is_in_group(name=group_members)
assert self.comptable.is_in_group(name=board_members)
self.comptable.memberships.update(end_date=localtime(now()))
assert not self.comptable.is_in_group(name=group_members)
assert not self.comptable.is_in_group(name=board_members)
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=3
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
membership.role = 1
membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
def test_club_owner(self):
"""Test that a club is owned only by board members of the main club."""
@ -517,6 +548,26 @@ class TestClubModel(TestClub):
Membership(club=self.ae, user=self.sli, role=3).save()
assert self.club.is_owned_by(self.sli)
def test_change_club_name(self):
"""Test that changing the club name doesn't break things."""
members_group = self.club.members_group
board_group = self.club.board_group
initial_members = set(members_group.users.values_list("id", flat=True))
initial_board = set(board_group.users.values_list("id", flat=True))
self.club.name = "something else"
self.club.save()
self.club.refresh_from_db()
# The names should have changed, but not the ids nor the group members
assert self.club.members_group.name == "something else - Membres"
assert self.club.board_group.name == "something else - Bureau"
assert self.club.members_group.id == members_group.id
assert self.club.board_group.id == board_group.id
new_members = set(self.club.members_group.users.values_list("id", flat=True))
new_board = set(self.club.board_group.users.values_list("id", flat=True))
assert new_members == initial_members
assert new_board == initial_board
class TestMailingForm(TestCase):
"""Perform validation tests for MailingForm."""

View File

@ -71,14 +71,13 @@ class ClubTabsMixin(TabedViewMixin):
return self.object.get_display_name()
def get_list_of_tabs(self):
tab_list = []
tab_list.append(
tab_list = [
{
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
"slug": "infos",
"name": _("Infos"),
}
)
]
if self.request.user.can_view(self.object):
tab_list.append(
{

32
com/api.py Normal file
View File

@ -0,0 +1,32 @@
from pathlib import Path
from django.conf import settings
from django.http import Http404
from ninja_extra import ControllerBase, api_controller, route
from com.calendar import IcsCalendar
from core.views.files import send_raw_file
@api_controller("/calendar")
class CalendarController(ControllerBase):
CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
@route.get("/external.ics", url_name="calendar_external")
def calendar_external(self):
"""Return the ICS file of the AE Google Calendar
Because of Google's cors rules, we can't just do a request to google ics
from the frontend. Google is blocking CORS request in it's responses headers.
The only way to do it from the frontend is to use Google Calendar API with an API key
This is not especially desirable as your API key is going to be provided to the frontend.
This is why we have this backend based solution.
"""
if (calendar := IcsCalendar.get_external()) is not None:
return send_raw_file(calendar)
raise Http404
@route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self):
return send_raw_file(IcsCalendar.get_internal())

9
com/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class ComConfig(AppConfig):
name = "com"
verbose_name = "News and communication"
def ready(self):
import com.signals # noqa F401

76
com/calendar.py Normal file
View File

@ -0,0 +1,76 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import final
import urllib3
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.event import Event
from com.models import NewsDate
@final
class IcsCalendar:
_CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
_EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics"
_INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics"
@classmethod
def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None:
if (
cls._EXTERNAL_CALENDAR.exists()
and timezone.make_aware(
datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime)
)
+ expiration
> timezone.now()
):
return cls._EXTERNAL_CALENDAR
return cls.make_external()
@classmethod
def make_external(cls) -> Path | None:
calendar = urllib3.request(
"GET",
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics",
)
if calendar.status != 200:
return None
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._EXTERNAL_CALENDAR, "wb") as f:
_ = f.write(calendar.data)
return cls._EXTERNAL_CALENDAR
@classmethod
def get_internal(cls) -> Path:
if not cls._INTERNAL_CALENDAR.exists():
return cls.make_internal()
return cls._INTERNAL_CALENDAR
@classmethod
def make_internal(cls) -> Path:
# Updated through a post_save signal on News in com.signals
calendar = Calendar()
for news_date in NewsDate.objects.filter(
news__is_moderated=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
).prefetch_related("news"):
event = Event(
summary=news_date.news.title,
start=news_date.start_date,
end=news_date.end_date,
url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
)
calendar.events.append(event)
# Create a file so we can offload the download to the reverse proxy if available
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8"))
return cls._INTERNAL_CALENDAR

View File

@ -0,0 +1,56 @@
# Generated by Django 4.2.17 on 2024-12-16 14:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0011_auto_20180426_2013"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("com", "0006_remove_sith_index_page"),
]
operations = [
migrations.AlterField(
model_name="news",
name="club",
field=models.ForeignKey(
help_text="The club which organizes the event.",
on_delete=django.db.models.deletion.CASCADE,
related_name="news",
to="club.club",
verbose_name="club",
),
),
migrations.AlterField(
model_name="news",
name="content",
field=models.TextField(
blank=True,
default="",
help_text="A more detailed and exhaustive description of the event.",
verbose_name="content",
),
),
migrations.AlterField(
model_name="news",
name="moderator",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_news",
to=settings.AUTH_USER_MODEL,
verbose_name="moderator",
),
),
migrations.AlterField(
model_name="news",
name="summary",
field=models.TextField(
help_text="A description of the event (what is the activity ? is there an associated clic ? is there a inscription form ?)",
verbose_name="summary",
),
),
]

View File

@ -17,11 +17,12 @@
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
@ -34,7 +35,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import Club
from core.models import Notification, Preferences, RealGroup, User
from core.models import Notification, Preferences, User
class Sith(models.Model):
@ -62,16 +63,31 @@ NEWS_TYPES = [
class News(models.Model):
"""The news class."""
"""News about club events."""
title = models.CharField(_("title"), max_length=64)
summary = models.TextField(_("summary"))
content = models.TextField(_("content"))
summary = models.TextField(
_("summary"),
help_text=_(
"A description of the event (what is the activity ? "
"is there an associated clic ? is there a inscription form ?)"
),
)
content = models.TextField(
_("content"),
blank=True,
default="",
help_text=_("A more detailed and exhaustive description of the event."),
)
type = models.CharField(
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
)
club = models.ForeignKey(
Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
Club,
related_name="news",
verbose_name=_("club"),
on_delete=models.CASCADE,
help_text=_("The club which organizes the event."),
)
author = models.ForeignKey(
User,
@ -85,7 +101,7 @@ class News(models.Model):
related_name="moderated_news",
verbose_name=_("moderator"),
null=True,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
)
def __str__(self):
@ -93,17 +109,15 @@ class News(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification(
user=u,
Notification.objects.create(
user=user,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
param="1",
).save()
)
def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id})
@ -321,16 +335,14 @@ class Poster(models.Model):
def save(self, *args, **kwargs):
if not self.is_moderated:
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification(
user=u,
Notification.objects.create(
user=user,
url=reverse("com:poster_moderate_list"),
type="POSTER_MODERATION",
).save()
)
return super().save(*args, **kwargs)
def clean(self, *args, **kwargs):

10
com/signals.py Normal file
View File

@ -0,0 +1,10 @@
from django.db.models.base import post_save
from django.dispatch import receiver
from com.calendar import IcsCalendar
from com.models import News
@receiver(post_save, sender=News, dispatch_uid="update_internal_ics")
def update_internal_ics(*args, **kwargs):
_ = IcsCalendar.make_internal()

View File

@ -0,0 +1,194 @@
import { makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { Calendar, type EventClickArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi";
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale"];
private calendar: Calendar;
private locale = "en";
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name !== "locale") {
return;
}
this.locale = newValue;
}
isMobile() {
return window.innerWidth < 765;
}
currentView() {
// Get view type based on viewport
return this.isMobile() ? "listMonth" : "dayGridMonth";
}
currentToolbar() {
if (this.isMobile()) {
return {
left: "prev,next",
center: "title",
right: "",
};
}
return {
left: "prev,next today",
center: "title",
right: "dayGridMonth,dayGridWeek,dayGridDay",
};
}
formatDate(date: Date) {
return new Intl.DateTimeFormat(this.locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
createEventDetailPopup(event: EventClickArg) {
// Delete previous popup
const oldPopup = document.getElementById("event-details");
if (oldPopup !== null) {
oldPopup.remove();
}
const makePopupInfo = (info: HTMLElement, iconClass: string) => {
const row = document.createElement("div");
const icon = document.createElement("i");
row.setAttribute("class", "event-details-row");
icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`);
row.appendChild(icon);
row.appendChild(info);
return row;
};
const makePopupTitle = (event: EventImpl) => {
const row = document.createElement("div");
row.innerHTML = `
<h4 class="event-details-row-content">
${event.title}
</h4>
<span class="event-details-row-content">
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
</span>
`;
return makePopupInfo(
row,
"fa-solid fa-calendar-days fa-xl event-detail-row-icon",
);
};
const makePopupLocation = (event: EventImpl) => {
if (event.extendedProps.location === null) {
return null;
}
const info = document.createElement("div");
info.innerText = event.extendedProps.location;
return makePopupInfo(info, "fa-solid fa-location-dot");
};
const makePopupUrl = (event: EventImpl) => {
if (event.url === "") {
return null;
}
const url = document.createElement("a");
url.href = event.url;
url.textContent = gettext("More info");
return makePopupInfo(url, "fa-solid fa-link");
};
// Create new popup
const popup = document.createElement("div");
const popupContainer = document.createElement("div");
popup.setAttribute("id", "event-details");
popupContainer.setAttribute("class", "event-details-container");
popupContainer.appendChild(makePopupTitle(event.event));
const location = makePopupLocation(event.event);
if (location !== null) {
popupContainer.appendChild(location);
}
const url = makePopupUrl(event.event);
if (url !== null) {
popupContainer.appendChild(url);
}
popup.appendChild(popupContainer);
// We can't just add the element relative to the one we want to appear under
// Otherwise, it either gets clipped by the boundaries of the calendar or resize cells
// Here, we create a popup outside the calendar that follows the clicked element
this.node.appendChild(popup);
const follow = (node: HTMLElement) => {
const rect = node.getBoundingClientRect();
popup.setAttribute(
"style",
`top: calc(${rect.top + window.scrollY}px + ${rect.height}px); left: ${rect.left + window.scrollX}px;`,
);
};
follow(event.el);
window.addEventListener("resize", () => {
follow(event.el);
});
}
async connectedCallback() {
super.connectedCallback();
this.calendar = new Calendar(this.node, {
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
locales: [frLocale, enLocale],
height: "auto",
locale: this.locale,
initialView: this.currentView(),
headerToolbar: this.currentToolbar(),
eventSources: [
{
url: await makeUrl(calendarCalendarInternal),
format: "ics",
},
{
url: await makeUrl(calendarCalendarExternal),
format: "ics",
},
],
windowResize: () => {
this.calendar.changeView(this.currentView());
this.calendar.setOption("headerToolbar", this.currentToolbar());
},
eventClick: (event) => {
// Avoid our popup to be deleted because we clicked outside of it
event.jsEvent.stopPropagation();
// Don't auto-follow events URLs
event.jsEvent.preventDefault();
this.createEventDetailPopup(event);
},
});
this.calendar.render();
window.addEventListener("click", (event: MouseEvent) => {
// Auto close popups when clicking outside of it
const popup = document.getElementById("event-details");
if (popup !== null && !popup.contains(event.target as Node)) {
popup.remove();
}
});
}
}

View File

@ -0,0 +1,101 @@
@import "core/static/core/colors";
:root {
--fc-button-border-color: #fff;
--fc-button-hover-border-color: #fff;
--fc-button-active-border-color: #fff;
--fc-button-text-color: #fff;
--fc-button-bg-color: #1a78b3;
--fc-button-active-bg-color: #15608F;
--fc-button-hover-bg-color: #15608F;
--fc-today-bg-color: rgba(26, 120, 179, 0.1);
--fc-border-color: #DDDDDD;
--event-details-background-color: white;
--event-details-padding: 20px;
--event-details-border: 1px solid #EEEEEE;
--event-details-border-radius: 4px;
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px;
}
ics-calendar {
border: none;
box-shadow: none;
#event-details {
z-index: 10;
max-width: 1151px;
position: absolute;
.event-details-container {
display: flex;
color: black;
flex-direction: column;
min-width: 200px;
max-width: var(--event-details-max-width);
padding: var(--event-details-padding);
border: var(--event-details-border);
border-radius: var(--event-details-border-radius);
background-color: var(--event-details-background-color);
box-shadow: var(--event-details-box-shadow);
gap: 20px;
}
.event-detail-row-icon {
margin-left: 10px;
margin-right: 20px;
align-content: center;
align-self: center;
}
.event-details-row {
display: flex;
align-items: start;
}
.event-details-row-content {
display: flex;
align-items: start;
flex-direction: row;
background-color: var(--event-details-background-color);
margin-top: 0px;
margin-bottom: 4px;
}
}
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
}
a.fc-daygrid-day-number,
a.fc-daygrid-day-number:hover {
color: rgb(34, 34, 34);
}
td {
overflow-x: visible; // Show events on multiple days
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0px;
-moz-border-radius: 0px;
margin: 0px;
}
// Reset from style.scss
thead {
background-color: white;
color: black;
}
// Reset from style.scss
tbody>tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
}

View File

@ -0,0 +1,61 @@
@import "core/static/core/colors";
#news_details {
display: inline-block;
margin-top: 20px;
padding: 0.4em;
width: 80%;
background: $white-color;
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
display: inline-block;
text-align: center;
width: 19%;
float: left;
min-width: 15em;
margin: 0;
img {
max-height: 15em;
max-width: 12em;
display: block;
margin: 0 auto;
margin-bottom: 10px;
}
}
.share_button {
border: none;
color: white;
padding: 0.5em 1em;
text-align: center;
text-decoration: none;
font-size: 1.2em;
border-radius: 2px;
float: right;
display: block;
margin-left: 0.3em;
&:hover {
color: lightgrey;
}
}
.facebook {
background: $faceblue;
}
.twitter {
background: $twitblue;
}
.news_meta {
margin-top: 10em;
font-size: small;
}
}

View File

@ -0,0 +1,297 @@
@import "core/static/core/colors";
@import "core/static/core/devices";
#news {
display: flex;
@media (max-width: 800px) {
flex-direction: column;
}
#news_admin {
margin-bottom: 1em;
}
#right_column {
flex: 20%;
margin: 3.2px;
display: inline-block;
vertical-align: top;
}
#left_column {
flex: 79%;
margin: 0.2em;
}
h3 {
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 17px;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
}
}
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
}
}
/* LINKS/BIRTHDAYS */
#links,
#birthdays {
display: block;
width: 100%;
background: white;
font-size: 70%;
margin-bottom: 1em;
h3 {
margin-bottom: 0;
}
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
height: 20em;
h4 {
margin-left: 5px;
}
ul {
list-style: none;
margin-left: 0;
li {
margin: 10px;
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
i {
width: 25px;
text-align: center;
}
}
}
}
#birthdays_content {
ul.birthdays_year {
margin: 0;
list-style-type: none;
font-weight: bold;
>li {
padding: 0.5em;
&:nth-child(even) {
background: $secondary-neutral-light-color;
}
}
ul {
margin: 0;
margin-left: 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
}
}
}
}
/* END AGENDA/BIRTHDAYS */
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em;
margin-bottom: 0.5em;
.news_events_group_date {
display: table-cell;
padding: 0.6em;
vertical-align: middle;
background: $primary-neutral-dark-color;
color: $white-color;
text-transform: uppercase;
text-align: center;
font-weight: bold;
font-family: monospace;
font-size: 1.4em;
border-radius: 7px 0 0 7px;
div {
margin: 0 auto;
.day {
font-size: 1.5em;
}
}
}
.news_events_group_items {
display: table-cell;
width: 100%;
.news_event:nth-of-type(odd) {
background: white;
}
.news_event:nth-of-type(even) {
background: $primary-neutral-light-color;
}
.news_event {
display: block;
padding: 0.4em;
&:not(:last-child) {
border-bottom: 1px solid grey;
}
div {
margin: 0.2em;
}
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
float: left;
min-width: 7em;
max-width: 9em;
margin: 0;
margin-right: 1em;
margin-top: 0.8em;
img {
max-height: 6em;
max-width: 8em;
display: block;
margin: 0 auto;
}
}
.news_date {
font-size: 100%;
}
.news_content {
clear: left;
.button_bar {
text-align: right;
.fb {
color: $faceblue;
}
.twitter {
color: $twitblue;
}
}
}
}
}
}
/* END EVENTS TODAY AND NEXT FEW DAYS */
/* COMING SOON */
.news_coming_soon {
display: list-item;
list-style-type: square;
list-style-position: inside;
margin-left: 1em;
padding-left: 0;
a {
font-weight: bold;
text-transform: uppercase;
}
.news_date {
font-size: 0.9em;
}
}
/* END COMING SOON */
/* NOTICES */
.news_notice {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
box-shadow: $shadow-color 0 0 2px;
border-radius: 18px 5px 18px 5px;
h4 {
margin: 0;
}
.news_content {
margin-left: 1em;
}
}
/* END NOTICES */
/* CALLS */
.news_call {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
border: 1px solid grey;
box-shadow: $shadow-color 1px 1px 1px;
h4 {
margin: 0;
}
.news_date {
font-size: 0.9em;
}
.news_content {
margin-left: 1em;
}
}
/* END CALLS */
.news_empty {
margin-left: 1em;
}
.news_date {
color: grey;
}
}

View File

@ -0,0 +1,230 @@
#poster_list,
#screen_list,
#poster_edit,
#screen_edit {
position: relative;
#title {
position: relative;
padding: 10px;
margin: 10px;
border-bottom: 2px solid black;
h3 {
display: flex;
justify-content: center;
align-items: center;
}
#links {
position: absolute;
display: flex;
bottom: 5px;
&.left {
left: 0;
}
&.right {
right: 0;
}
.link {
padding: 5px;
padding-left: 20px;
padding-right: 20px;
margin-left: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&.delete {
background-color: hsl(0, 100%, 40%);
}
}
}
}
#posters,
#screens {
position: relative;
display: flex;
flex-wrap: wrap;
#no-posters,
#no-screens {
display: flex;
justify-content: center;
align-items: center;
}
.poster,
.screen {
min-width: 10%;
max-width: 20%;
display: flex;
flex-direction: column;
margin: 10px;
border: 2px solid darkgrey;
border-radius: 4px;
padding: 10px;
background-color: lightgrey;
* {
display: flex;
justify-content: center;
align-items: center;
}
.name {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
}
.image {
flex-grow: 1;
position: relative;
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
img {
max-height: 20vw;
max-width: 100%;
}
&:hover {
&::before {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
top: 0;
left: 0;
z-index: 10;
content: "Click to expand";
color: white;
background-color: rgba(black, 0.5);
}
}
}
.dates {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
* {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-left: 5px;
margin-right: 5px;
}
.begin,
.end {
width: 48%;
}
.begin {
border-right: 1px solid whitesmoke;
padding-right: 2%;
}
}
.edit,
.moderate,
.slideshow {
padding: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&:nth-child(2n) {
margin-top: 5px;
margin-bottom: 5px;
}
}
.tooltip {
visibility: hidden;
width: 120px;
background-color: hsl(210, 20%, 98%);
color: hsl(0, 0%, 0%);
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 10;
ul {
margin-left: 0;
display: inline-block;
li {
display: list-item;
list-style-type: none;
}
}
}
&.not_moderated {
border: 1px solid red;
}
&:hover .tooltip {
visibility: visible;
}
}
}
#view {
position: fixed;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
z-index: 10;
visibility: hidden;
background-color: rgba(10, 10, 10, 0.9);
overflow: hidden;
&.active {
visibility: visible;
}
#placeholder {
width: 80vw;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
img {
max-width: 100%;
max-height: 100%;
}
}
}
}

View File

@ -11,6 +11,11 @@
{{ gen_news_metatags(news) }}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
{% endblock %}
{% block content %}
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
<section id="news_details">

View File

@ -34,24 +34,70 @@
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.author }}
<p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label>
<p>
{{ form.type.errors }}
<label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
<ul>
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
<li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li>
<li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
<li>
{% trans trimmed%}
Weekly: recurrent event, associated with many dates
(specify the first one, and a deadline)
{% endtrans %}
</li>
<li>
{% trans trimmed %}
Call: long time event, associated with a long date (like election appliance)
{% endtrans %}
</li>
</ul>
{{ form.type }}</p>
<p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</p>
<p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p>
<p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p>
<p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p>
<p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
<p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
<p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
{{ form.type }}
</p>
<p class="date">
{{ form.start_date.errors }}
<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
{{ form.start_date }}
</p>
<p class="date">
{{ form.end_date.errors }}
<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label>
{{ form.end_date }}
</p>
<p class="until">
{{ form.until.errors }}
<label for="{{ form.until.name }}">{{ form.until.label }}</label>
{{ form.until }}
</p>
<p>
{{ form.title.errors }}
<label for="{{ form.title.name }}" class="required">{{ form.title.label }}</label>
{{ form.title }}
</p>
<p>
{{ form.club.errors }}
<label for="{{ form.club.name }}" class="required">{{ form.club.label }}</label>
<span class="helptext">{{ form.club.help_text }}</span>
{{ form.club }}
</p>
<p>
{{ form.summary.errors }}
<label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label>
<span class="helptext">{{ form.summary.help_text }}</span>
{{ form.summary }}
</p>
<p>
{{ form.content.errors }}
<label for="{{ form.content.name }}">{{ form.content.label }}</label>
<span class="helptext">{{ form.content.help_text }}</span>
{{ form.content }}
</p>
{% if user.is_com_admin %}
<p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
{{ form.automoderation }}</p>
<p>
{{ form.automoderation.errors }}
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
{{ form.automoderation }}
</p>
{% endif %}
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
@ -62,15 +108,16 @@
{{ super() }}
<script>
$(function () {
var type = $('input[name=type]');
var dates = $('.date');
var until = $('.until');
let type = $('input[name=type]');
let dates = $('.date');
let until = $('.until');
function update_targets() {
type_checked = $('input[name=type]:checked');
if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
const type_checked = $('input[name=type]:checked');
if (["CALL", "EVENT"].includes(type_checked.val())) {
dates.show();
until.hide();
} else if (type_checked.val() == "WEEKLY") {
} else if (type_checked.val() === "WEEKLY") {
dates.show();
until.show();
} else {
@ -78,6 +125,7 @@
until.hide();
}
}
update_targets();
type.change(update_targets);
});

View File

@ -5,6 +5,15 @@
{% trans %}News{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
{% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
{% endblock %}
{% block content %}
{% if user.is_com_admin %}
<div id="news_admin">
@ -83,84 +92,78 @@
</div>
{% endif %}
{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5),
type="EVENT").order_by('dates__start_date') %}
{% if coming_soon %}
<h3>{% trans %}Coming soon... don't miss!{% endtrans %}</h3>
{% for news in coming_soon %}
<section class="news_coming_soon">
<a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a>
<span class="news_date">{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</section>
{% endfor %}
{% endif %}
<h3>{% trans %}All coming events{% endtrans %}</h3>
<iframe
src="https://embed.styledcalendar.com/#2mF2is8CEXhr4ADcX6qN"
title="Styled Calendar"
class="styled-calendar-container"
style="width: 100%; border: none; height: 1060px"
data-cy="calendar-embed-iframe">
</iframe>
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
</div>
<div id="right_column" class="news_column">
<div id="agenda">
<div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
<div id="agenda_content">
{% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
news__is_moderated=True, news__type__in=["WEEKLY",
"EVENT"]).order_by('start_date', 'end_date') %}
<div class="agenda_item">
<div class="agenda_date">
<strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
</div>
<div class="agenda_time">
<span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div>
<strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
<a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
</div>
<div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
</div>
{% endfor %}
<div id="right_column">
<div id="links">
<h3>{% trans %}Links{% endtrans %}</h3>
<div id="links_content">
<h4>{% trans %}Our services{% endtrans %}</h4>
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
</li>
</ul>
<br>
<h4>{% trans %}Social media{% endtrans %}</h4>
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/XK9WfPsUFm">{% trans %}Dev Team{% endtrans %}</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
</li>
</ul>
</div>
</div>
<div id="birthdays">
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
<h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content">
{% if user.is_subscribed %}
{# Cache request for 1 hour #}
{% cache 3600 "birthdays" %}
{%- if user.was_subscribed -%}
<ul class="birthdays_year">
{% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
{%- for year, users in birthdays -%}
<li>
{% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
<ul>
{% for u in birthdays.filter(date_of_birth__year=d.year) %}
{%- for u in users -%}
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
{% endfor %}
{%- endfor -%}
</ul>
</li>
{% endfor %}
{%- endfor -%}
</ul>
{% endcache %}
{% else %}
<p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
{% endif %}
{%- else -%}
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- endif -%}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -10,6 +10,10 @@
{% trans %}Poster{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %}
<div id="poster_list">

View File

@ -5,6 +5,10 @@
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %}
<div id="poster_list">

View File

@ -3,7 +3,7 @@
<head>
<title>{% trans %}Slideshow{% endtrans %}</title>
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/jquery-index.js') }}"></script>
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('com/js/slideshow.js') }}"></script>
</head>
<body>

122
com/tests/test_api.py Normal file
View File

@ -0,0 +1,122 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable
from unittest.mock import MagicMock, patch
import pytest
from django.conf import settings
from django.http import HttpResponse
from django.test.client import Client
from django.urls import reverse
from django.utils import timezone
from com.calendar import IcsCalendar
@dataclass
class MockResponse:
status: int
value: str
@property
def data(self):
return self.value.encode("utf8")
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):
return None
return settings.MEDIA_ROOT / redirect.relative_to(
Path("/") / settings.MEDIA_ROOT.stem
)
@pytest.mark.django_db
class TestExternalCalendar:
@pytest.fixture
def mock_request(self):
mock = MagicMock()
with patch("urllib3.request", mock):
yield mock
@pytest.fixture
def mock_current_time(self):
mock = MagicMock()
original = timezone.now
with patch("django.utils.timezone.now", mock):
yield mock, original
@pytest.fixture(autouse=True)
def clear_cache(self):
IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
@pytest.mark.parametrize("error_code", [403, 404, 500])
def test_fetch_error(
self, client: Client, mock_request: MagicMock, error_code: int
):
mock_request.return_value = MockResponse(error_code, "not allowed")
assert client.get(reverse("api:calendar_external")).status_code == 404
def test_fetch_success(self, client: Client, mock_request: MagicMock):
external_response = MockResponse(200, "Definitely an ICS")
mock_request.return_value = external_response
response = client.get(reverse("api:calendar_external"))
assert response.status_code == 200
out_file = accel_redirect_to_file(response)
assert out_file is not None
assert out_file.exists()
with open(out_file, "r") as f:
assert f.read() == external_response.value
def test_fetch_caching(
self,
client: Client,
mock_request: MagicMock,
mock_current_time: tuple[MagicMock, Callable[[], datetime]],
):
fake_current_time, original_timezone = mock_current_time
start_time = original_timezone()
fake_current_time.return_value = start_time
external_response = MockResponse(200, "Definitely an ICS")
mock_request.return_value = external_response
with open(
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
) as f:
assert f.read() == external_response.value
mock_request.return_value = MockResponse(200, "This should be ignored")
with open(
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
) as f:
assert f.read() == external_response.value
mock_request.assert_called_once()
fake_current_time.return_value = start_time + timedelta(hours=1, seconds=1)
external_response = MockResponse(200, "This won't be ignored")
mock_request.return_value = external_response
with open(
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
) as f:
assert f.read() == external_response.value
assert mock_request.call_count == 2
@pytest.mark.django_db
class TestInternalCalendar:
@pytest.fixture(autouse=True)
def clear_cache(self):
IcsCalendar._INTERNAL_CALENDAR.unlink(missing_ok=True)
def test_fetch_success(self, client: Client):
response = client.get(reverse("api:calendar_internal"))
assert response.status_code == 200
out_file = accel_redirect_to_file(response)
assert out_file is not None
assert out_file.exists()

View File

@ -23,7 +23,7 @@ from django.utils.translation import gettext as _
from club.models import Club, Membership
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
from core.models import AnonymousUser, RealGroup, User
from core.models import AnonymousUser, Group, User
@pytest.fixture()
@ -49,9 +49,7 @@ class TestCom(TestCase):
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
cls.com_group = RealGroup.objects.filter(
id=settings.SITH_GROUP_COM_ADMIN_ID
).first()
cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
cls.skia.groups.set([cls.com_group])
def setUp(self):
@ -99,9 +97,7 @@ class TestCom(TestCase):
response = self.client.get(reverse("core:index"))
self.assertContains(
response,
text=html.escape(
_("You need an up to date subscription to access this content")
),
text=html.escape(_("You need to subscribe to access this content")),
)
def test_birthday_subscibed_user(self):
@ -109,9 +105,16 @@ class TestCom(TestCase):
self.assertNotContains(
response,
text=html.escape(
_("You need an up to date subscription to access this content")
),
text=html.escape(_("You need to subscribe to access this content")),
)
def test_birthday_old_subscibed_user(self):
self.client.force_login(User.objects.get(username="old_subscriber"))
response = self.client.get(reverse("core:index"))
self.assertNotContains(
response,
text=html.escape(_("You need to subscribe to access this content")),
)

View File

@ -21,14 +21,14 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import itertools
from datetime import timedelta
from smtplib import SMTPRecipientsRefused
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max
from django.db.models import Exists, Max, OuterRef
from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
@ -42,7 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.models import Notification, RealGroup, User
from core.models import Notification, User
from core.views import (
CanCreateMixin,
CanEditMixin,
@ -223,15 +223,13 @@ class NewsForm(forms.ModelForm):
):
self.add_error(
"end_date",
ValidationError(
_("You crazy? You can not finish an event before starting it.")
),
ValidationError(_("An event cannot end before its beginning.")),
)
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
self.add_error("until", ValidationError(_("This field is required.")))
return self.cleaned_data
def save(self):
def save(self, *args, **kwargs):
ret = super().save()
self.instance.dates.all().delete()
if self.instance.type == "EVENT" or self.instance.type == "CALL":
@ -280,21 +278,18 @@ class NewsEditView(CanEditMixin, UpdateView):
else:
self.object.is_moderated = False
self.object.save()
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
if not u.notifications.filter(
type="NEWS_MODERATION", viewed=False
).exists():
Notification(
user=u,
url=reverse(
"com:news_detail", kwargs={"news_id": self.object.id}
),
Notification.objects.create(
user=user,
url=self.object.get_absolute_url(),
type="NEWS_MODERATION",
).save()
)
return super().form_valid(form)
@ -325,19 +320,18 @@ class NewsCreateView(CanCreateMixin, CreateView):
self.object.is_moderated = True
self.object.save()
else:
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
if not u.notifications.filter(
type="NEWS_MODERATION", viewed=False
).exists():
Notification(
user=u,
Notification.objects.create(
user=user,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
).save()
)
return super().form_valid(form)
@ -380,13 +374,14 @@ class NewsListView(CanViewMixin, ListView):
kwargs = super().get_context_data(**kwargs)
kwargs["NewsDate"] = NewsDate
kwargs["timedelta"] = timedelta
kwargs["birthdays"] = (
kwargs["birthdays"] = itertools.groupby(
User.objects.filter(
date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day,
)
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth")
.order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year,
)
return kwargs
@ -690,8 +685,12 @@ class PosterEditBaseView(UpdateView):
def get_initial(self):
return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_begin
else None,
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_end
else None,
}
def dispatch(self, request, *args, **kwargs):

View File

@ -15,17 +15,32 @@
from django.contrib import admin
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import Permission
from core.models import Group, OperationLog, Page, SithFile, User
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
admin.site.unregister(AuthGroup)
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
list_display = ("name", "description", "is_meta")
list_filter = ("is_meta",)
list_display = ("name", "description", "is_manually_manageable")
list_filter = ("is_manually_manageable",)
search_fields = ("name",)
autocomplete_fields = ("permissions",)
@admin.register(BanGroup)
class BanGroupAdmin(admin.ModelAdmin):
list_display = ("name", "description")
search_fields = ("name",)
autocomplete_fields = ("permissions",)
class UserBanInline(admin.TabularInline):
model = UserBan
extra = 0
autocomplete_fields = ("ban_group",)
@admin.register(User)
@ -37,10 +52,24 @@ class UserAdmin(admin.ModelAdmin):
"profile_pict",
"avatar_pict",
"scrub_pict",
"user_permissions",
"groups",
)
inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"]
@admin.register(UserBan)
class UserBanAdmin(admin.ModelAdmin):
list_display = ("user", "ban_group", "created_at", "expires_at")
autocomplete_fields = ("user", "ban_group")
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",)
@admin.register(Page)
class PageAdmin(admin.ModelAdmin):
list_display = ("name", "_full_name", "owner_group")

42
core/auth_backends.py Normal file
View File

@ -0,0 +1,42 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from core.models import Group
if TYPE_CHECKING:
from core.models import User
class SithModelBackend(ModelBackend):
"""Custom auth backend for the Sith.
In fact, it's the exact same backend as `django.contrib.auth.backend.ModelBackend`,
with the exception that group permissions are fetched slightly differently.
Indeed, django tries by default to fetch the permissions associated
with all the `django.contrib.auth.models.Group` of a user ;
however, our User model overrides that, so the actual linked group model
is [core.models.Group][].
Instead of having the relation `auth_perm --> auth_group <-- core_user`,
we have `auth_perm --> auth_group <-- core_group <-- core_user`.
Thus, this backend make the small tweaks necessary to make
our custom models interact with the django auth.
"""
def _get_group_permissions(self, user_obj: User):
# union of querysets doesn't work if the queryset is ordered.
# The empty `order_by` here are actually there to *remove*
# any default ordering defined in managers or model Meta
groups = user_obj.groups.order_by()
if user_obj.is_subscribed:
groups = groups.union(
Group.objects.filter(pk=settings.SITH_GROUP_SUBSCRIBERS_ID).order_by()
)
return Permission.objects.filter(
group__group__in=groups.values_list("pk", flat=True)
)

View File

@ -7,7 +7,7 @@ from model_bakery import seq
from model_bakery.recipe import Recipe, related
from club.models import Membership
from core.models import User
from core.models import Group, User
from subscription.models import Subscription
active_subscription = Recipe(
@ -60,5 +60,6 @@ board_user = Recipe(
first_name="AE",
last_name=seq("member "),
memberships=related(ae_board_membership),
groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)],
)
"""A user which is in the board of the AE."""

View File

@ -13,12 +13,42 @@
#
#
import hashlib
import multiprocessing
import os
import platform
import shutil
import subprocess
import sys
import tarfile
from dataclasses import dataclass
from pathlib import Path
from typing import Self
import tomli
from django.core.management.base import BaseCommand, CommandParser
import urllib3
from django.core.management.base import BaseCommand, CommandParser, OutputWrapper
from urllib3.response import HTTPException
@dataclass
class XapianSpec:
version: str
core_sha1: str
bindings_sha1: str
@classmethod
def from_pyproject(cls) -> Self:
with open(
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
) as f:
pyproject = tomli.load(f)
spec = pyproject["tool"]["xapian"]
return cls(
version=spec["version"],
core_sha1=spec["core-sha1"],
bindings_sha1=spec["bindings-sha1"],
)
class Command(BaseCommand):
@ -39,13 +69,6 @@ class Command(BaseCommand):
return None
return xapian.version_string()
def _desired_version(self) -> str:
with open(
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
) as f:
pyproject = tomli.load(f)
return pyproject["tool"]["xapian"]["version"]
def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None):
self.stdout.write(
@ -53,20 +76,185 @@ class Command(BaseCommand):
)
return
desired = self._desired_version()
if desired == self._current_version():
desired = XapianSpec.from_pyproject()
if desired.version == self._current_version():
if not force:
self.stdout.write(
f"Version {desired} is already installed, use --force to re-install"
f"Version {desired.version} is already installed, use --force to re-install"
)
return
self.stdout.write(f"Version {desired} is already installed, re-installing")
self.stdout.write(
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
)
subprocess.run(
[str(Path(__file__).parent / "install_xapian.sh"), desired],
env=dict(os.environ),
check=True,
f"Version {desired.version} is already installed, re-installing"
)
XapianInstaller(desired, self.stdout, self.stderr).run()
self.stdout.write("Installation success")
class XapianInstaller:
def __init__(
self,
spec: XapianSpec,
stdout: OutputWrapper,
stderr: OutputWrapper,
):
self._version = spec.version
self._core_sha1 = spec.core_sha1
self._bindings_sha1 = spec.bindings_sha1
self._stdout = stdout
self._stderr = stderr
self._virtual_env = os.environ.get("VIRTUAL_ENV", None)
if not self._virtual_env:
raise RuntimeError("You are not inside a virtual environment")
self._virtual_env = Path(self._virtual_env)
self._dest_dir = Path(self._virtual_env) / "packages"
self._core = f"xapian-core-{self._version}"
self._bindings = f"xapian-bindings-{self._version}"
@property
def _is_windows(self) -> bool:
return platform.system() == "Windows"
def _util_download(self, url: str, dest: Path, sha1_hash: str) -> None:
resp = urllib3.request("GET", url)
if resp.status != 200:
raise HTTPException(f"Could not download {url}")
if hashlib.sha1(resp.data).hexdigest() != sha1_hash:
raise ValueError(f"File downloaded from {url} is compromised")
with open(dest, "wb") as f:
f.write(resp.data)
def _setup_env(self):
os.environ.update(
{
"CPATH": "",
"LIBRARY_PATH": "",
"CFLAGS": "",
"LDFLAGS": "",
"CCFLAGS": "",
"CXXFLAGS": "",
"CPPFLAGS": "",
}
)
def _prepare_dest_folder(self):
shutil.rmtree(self._dest_dir, ignore_errors=True)
self._dest_dir.mkdir(parents=True, exist_ok=True)
def _download(self):
self._stdout.write("Downloading source…")
core = self._dest_dir / f"{self._core}.tar.xz"
bindings = self._dest_dir / f"{self._bindings}.tar.xz"
self._util_download(
f"https://oligarchy.co.uk/xapian/{self._version}/{self._core}.tar.xz",
core,
self._core_sha1,
)
self._util_download(
f"https://oligarchy.co.uk/xapian/{self._version}/{self._bindings}.tar.xz",
bindings,
self._bindings_sha1,
)
self._stdout.write("Extracting source …")
with tarfile.open(core) as tar:
tar.extractall(self._dest_dir)
with tarfile.open(bindings) as tar:
tar.extractall(self._dest_dir)
os.remove(core)
os.remove(bindings)
def _install(self):
self._stdout.write("Installing Xapian-core…")
def configure() -> list[str]:
if self._is_windows:
return ["sh", "configure"]
return ["./configure"]
def enable_static() -> list[str]:
if self._is_windows:
return ["--enable-shared", "--disable-static"]
return []
# Make sure that xapian finds the correct executable
os.environ["PYTHON3"] = str(Path(sys.executable).as_posix())
subprocess.run(
[*configure(), "--prefix", str(self._virtual_env.as_posix()), *enable_static(),],
env=dict(os.environ),
cwd=self._dest_dir / self._core,
check=False,
shell=self._is_windows,
).check_returncode()
subprocess.run(
[
"make",
"-j",
str(multiprocessing.cpu_count()),
],
env=dict(os.environ),
cwd=self._dest_dir / self._core,
check=False,
shell=self._is_windows,
).check_returncode()
subprocess.run(
["make", "install"],
env=dict(os.environ),
cwd=self._dest_dir / self._core,
check=False,
shell=self._is_windows,
).check_returncode()
self._stdout.write("Installing Xapian-bindings")
subprocess.run(
[
*configure(),
"--prefix",
str(self._virtual_env.as_posix()),
"--with-python3",
f"XAPIAN_CONFIG={(self._virtual_env / 'bin'/'xapian-config').as_posix()}",
*enable_static(),
],
env=dict(os.environ),
cwd=self._dest_dir / self._bindings,
check=False,
shell=self._is_windows,
).check_returncode()
subprocess.run(
[
"make",
"-j",
str(multiprocessing.cpu_count()),
],
env=dict(os.environ),
cwd=self._dest_dir / self._bindings,
check=False,
shell=self._is_windows,
).check_returncode()
subprocess.run(
["make", "install"],
env=dict(os.environ),
cwd=self._dest_dir / self._bindings,
check=False,
shell=self._is_windows,
).check_returncode()
def _post_clean(self):
shutil.rmtree(self._dest_dir, ignore_errors=True)
def _test(self):
subprocess.run(
[sys.executable, "-c", "import xapian"], check=False, shell=self._is_windows,
).check_returncode()
def run(self):
self._setup_env()
self._prepare_dest_folder()
self._download()
self._install()
self._post_clean()
self._test()

View File

@ -1,47 +0,0 @@
#!/usr/bin/env bash
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
# first argument of the script is Xapian version (e.g. 1.2.19)
VERSION="$1"
# Cleanup env vars for auto discovery mechanism
export CPATH=
export LIBRARY_PATH=
export CFLAGS=
export LDFLAGS=
export CCFLAGS=
export CXXFLAGS=
export CPPFLAGS=
# prepare
rm -rf "$VIRTUAL_ENV/packages"
mkdir -p "$VIRTUAL_ENV/packages" && cd "$VIRTUAL_ENV/packages" || exit 1
CORE=xapian-core-$VERSION
BINDINGS=xapian-bindings-$VERSION
# download
echo "Downloading source..."
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
# extract
echo "Extracting source..."
tar xf "${CORE}.tar.xz"
tar xf "${BINDINGS}.tar.xz"
# install
echo "Installing Xapian-core..."
cd "$VIRTUAL_ENV/packages/${CORE}" || exit 1
./configure --prefix="$VIRTUAL_ENV" && make -j"$(nproc)" && make install
PYTHON_FLAG=--with-python3
echo "Installing Xapian-bindings..."
cd "$VIRTUAL_ENV/packages/${BINDINGS}" || exit 1
./configure --prefix="$VIRTUAL_ENV" $PYTHON_FLAG XAPIAN_CONFIG="$VIRTUAL_ENV/bin/xapian-config" && make -j"$(nproc)" && make install
# clean
rm -rf "$VIRTUAL_ENV/packages"
# test
python -c "import xapian"

View File

@ -23,7 +23,7 @@
from datetime import date, timedelta
from io import StringIO
from pathlib import Path
from typing import ClassVar
from typing import ClassVar, NamedTuple
from django.conf import settings
from django.contrib.auth.models import Permission
@ -31,6 +31,7 @@ from django.contrib.sites.models import Site
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import connection
from django.db.models import Q
from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
@ -45,8 +46,9 @@ from accounting.models import (
SimplifiedAccountingType,
)
from club.models import Club, Membership
from com.calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import Group, Page, PageRev, RealGroup, SithFile, User
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from counter.models import Counter, Product, ProductType, StudentCard
from election.models import Candidature, Election, ElectionList, Role
@ -56,6 +58,18 @@ from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
class PopulatedGroups(NamedTuple):
root: Group
public: Group
subscribers: Group
old_subscribers: Group
sas_admin: Group
com_admin: Group
counter_admin: Group
accounting_admin: Group
pedagogy_admin: Group
class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = (
@ -69,7 +83,7 @@ class Command(BaseCommand):
# sqlite doesn't support this operation
return
sqlcmd = StringIO()
call_command("sqlsequencereset", *args, stdout=sqlcmd)
call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
cursor = connection.cursor()
cursor.execute(sqlcmd.getvalue())
@ -79,25 +93,8 @@ class Command(BaseCommand):
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
root_group = Group.objects.create(name="Root")
public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers")
old_subscribers = Group.objects.create(name="Old subscribers")
Group.objects.create(name="Accounting admin")
Group.objects.create(name="Communication admin")
Group.objects.create(name="Counter admin")
Group.objects.create(name="Banned from buying alcohol")
Group.objects.create(name="Banned from counters")
Group.objects.create(name="Banned to subscribe")
Group.objects.create(name="SAS admin")
Group.objects.create(name="Forum admin")
Group.objects.create(name="Pedagogy admin")
self.reset_index("core", "auth")
change_billing = Permission.objects.get(codename="change_billinginfo")
add_billing = Permission.objects.get(codename="add_billinginfo")
root_group.permissions.add(change_billing, add_billing)
groups = self._create_groups()
self._create_ban_groups()
root = User.objects.create_superuser(
id=0,
@ -137,11 +134,10 @@ class Command(BaseCommand):
)
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
self.reset_index("counter")
counters = [
*[
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR")
for bar_id, bar_name in settings.SITH_COUNTER_BARS
],
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
@ -149,14 +145,16 @@ class Command(BaseCommand):
Counter.objects.bulk_create(counters)
bar_groups = []
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
group = RealGroup.objects.create(name=f"{bar_name} admin")
group = Group.objects.create(
name=f"{bar_name} admin", is_manually_manageable=True
)
bar_groups.append(
Counter.edit_groups.through(counter_id=bar_id, group=group)
)
Counter.edit_groups.through.objects.bulk_create(bar_groups)
self.reset_index("counter")
subscribers.viewable_files.add(home_root, club_root)
groups.subscribers.viewable_files.add(home_root, club_root)
Weekmail().save()
@ -261,21 +259,11 @@ class Command(BaseCommand):
)
User.groups.through.objects.bulk_create(
[
User.groups.through(
realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
),
User.groups.through(group=groups.counter_admin, user=counter),
User.groups.through(group=groups.accounting_admin, user=comptable),
User.groups.through(group=groups.com_admin, user=comunity),
User.groups.through(group=groups.pedagogy_admin, user=tutu),
User.groups.through(group=groups.sas_admin, user=skia),
]
)
for user in richard, sli, krophil, skia:
@ -336,7 +324,7 @@ Welcome to the wiki page!
content="Fonctionnement de la laverie",
)
public_group.viewable_page.set(
groups.public.viewable_page.set(
[syntax_page, services_page, index_page, laundry_page]
)
@ -382,46 +370,42 @@ Welcome to the wiki page!
parent=main_club,
)
Membership.objects.bulk_create(
[
Membership(user=skia, club=main_club, role=3),
Membership(
Membership.objects.create(user=skia, club=main_club, role=3)
Membership.objects.create(
user=comunity,
club=bar_club,
start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
),
Membership(
)
Membership.objects.create(
user=sli,
club=troll,
role=9,
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
),
Membership(
)
Membership.objects.create(
user=krophil,
club=troll,
role=10,
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
),
Membership(
)
Membership.objects.create(
user=skia,
club=troll,
role=2,
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
),
Membership(
)
Membership.objects.create(
user=richard,
club=troll,
role=2,
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
),
]
)
p = ProductType.objects.create(name="Bières bouteilles")
@ -513,8 +497,10 @@ Welcome to the wiki page!
club=main_club,
limit_age=18,
)
subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
old_subscribers.products.add(cotis, cotis2)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
groups.old_subscribers.products.add(cotis, cotis2)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
@ -608,7 +594,6 @@ Welcome to the wiki page!
)
# Create an election
ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
el = Election.objects.create(
title="Élection 2017",
description="La roue tourne",
@ -617,10 +602,10 @@ Welcome to the wiki page!
start_date="1942-06-12 10:28:45+01",
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(public_group)
el.edit_groups.add(ae_board_group)
el.candidature_groups.add(subscribers)
el.vote_groups.add(subscribers)
el.view_groups.add(groups.public)
el.edit_groups.add(main_club.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
listeT = ElectionList.objects.create(title="Troll", election=el)
pres = Role.objects.create(
@ -755,7 +740,7 @@ Welcome to the wiki page!
NewsDate(
news=n,
start_date=friday + timedelta(hours=24 * 7 + 1),
end_date=self.now + timedelta(hours=24 * 7 + 9),
end_date=friday + timedelta(hours=24 * 7 + 9),
)
)
# Weekly
@ -781,8 +766,9 @@ Welcome to the wiki page!
]
)
NewsDate.objects.bulk_create(news_dates)
IcsCalendar.make_internal() # Force refresh of the calendar after a bulk_create
# Create som data for pedagogy
# Create some data for pedagogy
UV(
code="PA00",
@ -899,3 +885,114 @@ Welcome to the wiki page!
start=s.subscription_start,
)
s.save()
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()
root_group = Group.objects.create(name="Root", is_manually_manageable=True)
root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
# public has no permission.
# Its purpose is not to link users to permissions,
# but to other objects (like products)
public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers")
old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add(
*list(
perms.filter(
codename__in=[
"view_user",
"view_picture",
"view_album",
"view_peoplepicturerelation",
"add_peoplepicturerelation",
]
)
)
)
accounting_admin = Group.objects.create(
name="Accounting admin", is_manually_manageable=True
)
accounting_admin.permissions.add(
*list(
perms.filter(
Q(content_type__app_label="accounting")
| Q(
codename__in=[
"view_customer",
"view_product",
"change_product",
"add_product",
"view_producttype",
"change_producttype",
"add_producttype",
"delete_selling",
]
)
).values_list("pk", flat=True)
)
)
com_admin = Group.objects.create(
name="Communication admin", is_manually_manageable=True
)
com_admin.permissions.add(
*list(
perms.filter(content_type__app_label="com").values_list("pk", flat=True)
)
)
counter_admin = Group.objects.create(
name="Counter admin", is_manually_manageable=True
)
counter_admin.permissions.add(
*list(
perms.filter(
Q(content_type__app_label__in=["counter", "launderette"])
& ~Q(codename__in=["delete_product", "delete_producttype"])
)
)
)
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
sas_admin.permissions.add(
*list(
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
)
)
forum_admin = Group.objects.create(
name="Forum admin", is_manually_manageable=True
)
forum_admin.permissions.add(
*list(
perms.filter(content_type__app_label="forum").values_list(
"pk", flat=True
)
)
)
pedagogy_admin = Group.objects.create(
name="Pedagogy admin", is_manually_manageable=True
)
pedagogy_admin.permissions.add(
*list(
perms.filter(content_type__app_label="pedagogy").values_list(
"pk", flat=True
)
)
)
self.reset_index("core", "auth")
return PopulatedGroups(
root=root_group,
public=public_group,
subscribers=subscribers,
old_subscribers=old_subscribers,
com_admin=com_admin,
counter_admin=counter_admin,
accounting_admin=accounting_admin,
sas_admin=sas_admin,
pedagogy_admin=pedagogy_admin,
)
def _create_ban_groups(self):
BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", description="")
BanGroup.objects.create(name="Banned to subscribe", description="")

View File

@ -11,7 +11,7 @@ from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
from core.models import RealGroup, User
from core.models import Group, User
from counter.models import (
Counter,
Customer,
@ -173,7 +173,8 @@ class Command(BaseCommand):
club=club,
)
)
Membership.objects.bulk_create(memberships)
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_uvs(self):
root = User.objects.get(username="root")
@ -225,9 +226,7 @@ class Command(BaseCommand):
ae = Club.objects.get(unix_name="ae")
other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list(
RealGroup.objects.filter(
name__in=["Subscribers", "Old subscribers", "Public"]
)
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
)
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])

View File

@ -563,14 +563,21 @@ class Migration(migrations.Migration):
fields=[],
options={"proxy": True},
bases=("core.group",),
managers=[("objects", core.models.MetaGroupManager())],
managers=[("objects", django.contrib.auth.models.GroupManager())],
),
# at first, there existed a RealGroupManager and a RealGroupManager,
# which have been since been removed.
# However, this removal broke the migrations because it caused an ImportError.
# Thus, the managers MetaGroupManager (above) and RealGroupManager (below)
# have been replaced by the base django GroupManager to fix the import.
# As those managers aren't actually used in migrations,
# this replacement doesn't break anything.
migrations.CreateModel(
name="RealGroup",
fields=[],
options={"proxy": True},
bases=("core.group",),
managers=[("objects", core.models.RealGroupManager())],
managers=[("objects", django.contrib.auth.models.GroupManager())],
),
migrations.AlterUniqueTogether(
name="page", unique_together={("name", "parent")}

View File

@ -0,0 +1,82 @@
# Generated by Django 4.2.16 on 2024-11-20 16:22
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("core", "0039_alter_user_managers"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={"verbose_name": "user", "verbose_name_plural": "users"},
),
migrations.AddField(
model_name="user",
name="user_permissions",
field=models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
migrations.AlterField(
model_name="user",
name="date_joined",
field=models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
migrations.AlterField(
model_name="user",
name="is_superuser",
field=models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
migrations.AlterField(
model_name="user",
name="username",
field=models.CharField(
error_messages={"unique": "A user with that username already exists."},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name="username",
),
),
migrations.AlterField(
model_name="user",
name="groups",
field=models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
migrations.AlterField(
model_name="user",
name="groups",
field=models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="users",
to="core.group",
verbose_name="groups",
),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 4.2.16 on 2024-11-30 13:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0040_alter_user_options_user_user_permissions_and_more"),
("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
]
operations = [
migrations.DeleteModel(
name="MetaGroup",
),
migrations.DeleteModel(
name="RealGroup",
),
migrations.AlterModelOptions(
name="group",
options={},
),
migrations.RenameField(
model_name="group",
old_name="is_meta",
new_name="is_manually_manageable",
),
migrations.AlterField(
model_name="group",
name="is_manually_manageable",
field=models.BooleanField(
default=False,
help_text="If False, this shouldn't be shown on group management pages",
verbose_name="Is manually manageable",
),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-04 16:42
from django.db import migrations
from django.db.migrations.state import StateApps
from django.db.models import F
def invert_is_manually_manageable(apps: StateApps, schema_editor):
"""Invert `is_manually_manageable`.
This field is a renaming of `is_meta`.
However, the meaning has been inverted : the groups
which were meta are not manually manageable and vice versa.
Thus, the value must be inverted.
"""
Group = apps.get_model("core", "Group")
Group.objects.all().update(is_manually_manageable=~F("is_manually_manageable"))
class Migration(migrations.Migration):
dependencies = [("core", "0041_delete_metagroup_alter_group_options_and_more")]
operations = [
migrations.RunPython(
invert_is_manually_manageable, reverse_code=invert_is_manually_manageable
),
]

View File

@ -0,0 +1,164 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30
import django.contrib.auth.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
def migrate_ban_groups(apps: StateApps, schema_editor):
Group = apps.get_model("core", "Group")
BanGroup = apps.get_model("core", "BanGroup")
ban_group_ids = [
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
settings.SITH_GROUP_BANNED_COUNTER_ID,
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
]
# this is a N+1 Queries, but the prod database has a grand total of 3 ban groups
for group in Group.objects.filter(id__in=ban_group_ids):
# auth_group, which both Group and BanGroup inherit,
# is unique by name.
# If we tried give the exact same name to the migrated BanGroup
# before deleting the corresponding Group,
# we would have an IntegrityError.
# So we append a space to the name, in order to create a name
# that will look the same, but that isn't really the same.
ban_group = BanGroup.objects.create(
name=f"{group.name} ",
description=group.description,
)
perms = list(group.permissions.values_list("id", flat=True))
if perms:
ban_group.permissions.add(*perms)
ban_group.users.add(
*group.users.values_list("id", flat=True), through_defaults={"reason": ""}
)
group.delete()
# now that the original group is no longer there,
# we can remove the appended space
ban_group.name = ban_group.name.strip()
ban_group.save()
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("core", "0042_invert_is_manually_manageable_20250104_1742"),
]
operations = [
migrations.CreateModel(
name="BanGroup",
fields=[
(
"group_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="auth.group",
),
),
("description", models.TextField(verbose_name="description")),
],
bases=("auth.group",),
managers=[
("objects", django.contrib.auth.models.GroupManager()),
],
options={
"verbose_name": "ban group",
"verbose_name_plural": "ban groups",
},
),
migrations.AlterField(
model_name="group",
name="description",
field=models.TextField(verbose_name="description"),
),
migrations.AlterField(
model_name="user",
name="groups",
field=models.ManyToManyField(
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="users",
to="core.group",
verbose_name="groups",
),
),
migrations.CreateModel(
name="UserBan",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"expires_at",
models.DateTimeField(
blank=True,
help_text="When the ban should be removed. Currently, there is no automatic removal, so this is purely indicative. Automatic ban removal may be implemented later on.",
null=True,
verbose_name="expires at",
),
),
("reason", models.TextField(verbose_name="reason")),
(
"ban_group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_bans",
to="core.bangroup",
verbose_name="ban type",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bans",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
),
migrations.AddField(
model_name="user",
name="ban_groups",
field=models.ManyToManyField(
help_text="The bans this user has received.",
related_name="users",
through="core.UserBan",
to="core.bangroup",
verbose_name="ban groups",
),
),
migrations.AddConstraint(
model_name="userban",
constraint=models.UniqueConstraint(
fields=("ban_group", "user"), name="unique_ban_type_per_user"
),
),
migrations.AddConstraint(
model_name="userban",
constraint=models.CheckConstraint(
check=models.Q(("expires_at__gte", models.F("created_at"))),
name="user_ban_end_after_start",
),
),
migrations.RunPython(
migrate_ban_groups, reverse_code=migrations.RunPython.noop, elidable=True
),
]

View File

@ -30,26 +30,19 @@ import string
import unicodedata
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Self
from typing import TYPE_CHECKING, Optional, Self
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, UserManager
from django.contrib.auth.models import (
AnonymousUser as AuthAnonymousUser,
)
from django.contrib.auth.models import (
Group as AuthGroup,
)
from django.contrib.auth.models import (
GroupManager as AuthGroupManager,
)
from django.contrib.auth.models import AbstractUser, UserManager
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models import Exists, OuterRef, Q
from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
@ -64,33 +57,15 @@ if TYPE_CHECKING:
from club.models import Club
class RealGroupManager(AuthGroupManager):
def get_queryset(self):
return super().get_queryset().filter(is_meta=False)
class MetaGroupManager(AuthGroupManager):
def get_queryset(self):
return super().get_queryset().filter(is_meta=True)
class Group(AuthGroup):
"""Implement both RealGroups and Meta groups.
"""Wrapper around django.auth.Group"""
Groups are sorted by their is_meta property
"""
#: If False, this is a RealGroup
is_meta = models.BooleanField(
_("meta group status"),
is_manually_manageable = models.BooleanField(
_("Is manually manageable"),
default=False,
help_text=_("Whether a group is a meta group or not"),
help_text=_("If False, this shouldn't be shown on group management pages"),
)
#: Description of the group
description = models.CharField(_("description"), max_length=60)
class Meta:
ordering = ["name"]
description = models.TextField(_("description"))
def get_absolute_url(self) -> str:
return reverse("core:group_list")
@ -106,65 +81,6 @@ class Group(AuthGroup):
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
class MetaGroup(Group):
"""MetaGroups are dynamically created groups.
Generally used with clubs where creating a club creates two groups:
* club-SITH_BOARD_SUFFIX
* club-SITH_MEMBER_SUFFIX
"""
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False
objects = MetaGroupManager()
class Meta:
proxy = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_meta = True
@cached_property
def associated_club(self) -> Club | None:
"""Return the group associated with this meta group.
The result of this function is cached
Returns:
The associated club if it exists, else None
"""
from club.models import Club
if self.name.endswith(settings.SITH_BOARD_SUFFIX):
# replace this with str.removesuffix as soon as Python
# is upgraded to 3.10
club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]
elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):
club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]
else:
return None
club = cache.get(f"sith_club_{club_name}")
if club is None:
club = Club.objects.filter(unix_name=club_name).first()
cache.set(f"sith_club_{club_name}", club)
return club
class RealGroup(Group):
"""RealGroups are created by the developer.
Most of the time they match a number in settings to be easily used for permissions.
"""
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True
objects = RealGroupManager()
class Meta:
proxy = True
def validate_promo(value: int) -> None:
start_year = settings.SITH_SCHOOL_START_YEAR
delta = (localdate() + timedelta(days=180)).year - start_year
@ -210,13 +126,35 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
else:
group = Group.objects.filter(name=name).first()
if group is not None:
cache.set(f"sith_group_{group.id}", group)
cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
name = group.name.replace(" ", "_")
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
else:
cache.set(f"sith_group_{pk_or_name}", "not_found")
return group
class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them.
Users are linked to BanGroups through UserBan objects.
Example:
```python
user = User.objects.get(username="...")
ban_group = BanGroup.objects.first()
UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
assert user.ban_groups.contains(ban_group)
```
"""
description = models.TextField(_("description"))
class Meta:
verbose_name = _("ban group")
verbose_name_plural = _("ban groups")
class UserQuerySet(models.QuerySet):
def filter_inactive(self) -> Self:
from counter.models import Refilling, Selling
@ -242,7 +180,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
pass
class User(AbstractBaseUser):
class User(AbstractUser):
"""Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it,
@ -253,51 +191,28 @@ class User(AbstractBaseUser):
Required fields: email, first_name, last_name, date_of_birth
"""
username = models.CharField(
_("username"),
max_length=254,
unique=True,
help_text=_(
"Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
),
validators=[
validators.RegexValidator(
r"^[\w.+-]+$",
_(
"Enter a valid username. This value may contain only "
"letters, numbers "
"and ./+/-/_ characters."
),
)
],
error_messages={"unique": _("A user with that username already exists.")},
)
first_name = models.CharField(_("first name"), max_length=64)
last_name = models.CharField(_("last name"), max_length=64)
email = models.EmailField(_("email address"), unique=True)
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Designates whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"Designates whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
date_joined = models.DateField(_("date joined"), auto_now_add=True)
last_update = models.DateTimeField(_("last update"), auto_now=True)
is_superuser = models.BooleanField(
_("superuser"),
default=False,
help_text=_("Designates whether this user is a superuser. "),
groups = models.ManyToManyField(
Group,
verbose_name=_("groups"),
help_text=_(
"The groups this user belongs to. A user will get all permissions "
"granted to each of their groups."
),
related_name="users",
)
ban_groups = models.ManyToManyField(
BanGroup,
verbose_name=_("ban groups"),
through="UserBan",
help_text=_("The bans this user has received."),
related_name="users",
)
groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
home = models.OneToOneField(
"SithFile",
related_name="home_of",
@ -401,8 +316,6 @@ class User(AbstractBaseUser):
objects = CustomUserManager()
USERNAME_FIELD = "username"
def __str__(self):
return self.get_display_name()
@ -422,22 +335,23 @@ class User(AbstractBaseUser):
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists()
def has_module_perms(self, package_name: str) -> bool:
return self.is_active
def has_perm(self, perm: str, obj: Any = None) -> bool:
return self.is_active and self.is_superuser
@cached_property
def was_subscribed(self) -> bool:
if "is_subscribed" in self.__dict__ and self.is_subscribed:
# if the user is currently subscribed, he is an old subscriber too
# if the property has already been cached, avoid another request
return True
return self.subscriptions.exists()
@cached_property
def is_subscribed(self) -> bool:
s = self.subscriptions.filter(
if "was_subscribed" in self.__dict__ and not self.was_subscribed:
# if the user never subscribed, he cannot be a subscriber now.
# if the property has already been cached, avoid another request
return False
return self.subscriptions.filter(
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
)
return s.exists()
).exists()
@cached_property
def account_balance(self):
@ -474,18 +388,6 @@ class User(AbstractBaseUser):
return self.was_subscribed
if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
if group.is_meta:
# check if this group is associated with a club
group.__class__ = MetaGroup
club = group.associated_club
if club is None:
return False
membership = club.get_membership_for(self)
if membership is None:
return False
if group.name.endswith(settings.SITH_MEMBER_SUFFIX):
return True
return membership.role > settings.SITH_MAXIMUM_FREE_ROLE
return group in self.cached_groups
@property
@ -510,12 +412,11 @@ class User(AbstractBaseUser):
return any(g.id == root_id for g in self.cached_groups)
@cached_property
def is_board_member(self):
main_club = settings.SITH_MAIN_CLUB["unix_name"]
return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
def is_board_member(self) -> bool:
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
@cached_property
def can_read_subscription_history(self):
def can_read_subscription_history(self) -> bool:
if self.is_root or self.is_board_member:
return True
@ -529,13 +430,13 @@ class User(AbstractBaseUser):
return False
@cached_property
def can_create_subscription(self):
from club.models import Club
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
if club in self.clubs_with_rights:
return True
return False
def can_create_subscription(self) -> bool:
return self.is_root or (
self.memberships.board()
.ongoing()
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
.exists()
)
@cached_property
def is_launderette_manager(self):
@ -550,12 +451,12 @@ class User(AbstractBaseUser):
)
@cached_property
def is_banned_alcohol(self):
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
def is_banned_alcohol(self) -> bool:
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
@cached_property
def is_banned_counter(self):
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
def is_banned_counter(self) -> bool:
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
@cached_property
def age(self) -> int:
@ -599,11 +500,6 @@ class User(AbstractBaseUser):
"date_of_birth": self.date_of_birth,
}
def get_full_name(self):
"""Returns the first_name plus the last_name, with a space in between."""
full_name = "%s %s" % (self.first_name, self.last_name)
return full_name.strip()
def get_short_name(self):
"""Returns the short name for the user."""
if self.nick_name:
@ -619,14 +515,6 @@ class User(AbstractBaseUser):
return "%s (%s)" % (self.get_full_name(), self.nick_name)
return self.get_full_name()
def get_age(self):
"""Returns the age."""
today = timezone.now()
born = self.date_of_birth
return (
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
)
def get_family(
self,
godfathers_depth: NonNegativeInt = 4,
@ -870,6 +758,52 @@ class AnonymousUser(AuthAnonymousUser):
return _("Visitor")
class UserBan(models.Model):
"""A ban of a user.
A user can be banned for a specific reason, for a specific duration.
The expiration date is indicative, and the ban should be removed manually.
"""
ban_group = models.ForeignKey(
BanGroup,
verbose_name=_("ban type"),
related_name="user_bans",
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
expires_at = models.DateTimeField(
_("expires at"),
null=True,
blank=True,
help_text=_(
"When the ban should be removed. "
"Currently, there is no automatic removal, so this is purely indicative. "
"Automatic ban removal may be implemented later on."
),
)
reason = models.TextField(_("reason"))
class Meta:
verbose_name = _("user ban")
verbose_name_plural = _("user bans")
constraints = [
models.UniqueConstraint(
fields=["ban_group", "user"], name="unique_ban_type_per_user"
),
models.CheckConstraint(
check=Q(expires_at__gte=F("created_at")),
name="user_ban_end_after_start",
),
]
def __str__(self):
return f"Ban of user {self.user.id}"
class Preferences(models.Model):
user = models.OneToOneField(
User, related_name="_preferences", on_delete=models.CASCADE
@ -982,19 +916,17 @@ class SithFile(models.Model):
if copy_rights:
self.copy_rights()
if self.is_in_sas:
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
.first()
.users.all()
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=u,
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user):
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
return False
if user.is_root:
@ -1009,7 +941,7 @@ class SithFile(models.Model):
return True
return user.id == self.owner_id
def can_be_viewed_by(self, user):
def can_be_viewed_by(self, user: User) -> bool:
if hasattr(self, "profile_of"):
return user.can_view(self.profile_of)
if hasattr(self, "avatar_of"):

View File

@ -4,6 +4,7 @@ from typing import Annotated
from annotated_types import MinLen
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Q
from django.urls import reverse
from django.utils.text import slugify
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
@ -37,13 +38,13 @@ class UserProfileSchema(ModelSchema):
@staticmethod
def resolve_profile_url(obj: User) -> str:
return obj.get_absolute_url()
return reverse("core:user_profile", kwargs={"user_id": obj.pk})
@staticmethod
def resolve_profile_pict(obj: User) -> str:
if obj.profile_pict_id is None:
return staticfiles_storage.url("core/img/unknown.jpg")
return obj.profile_pict.get_download_url()
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
class SithFileSchema(ModelSchema):

View File

@ -1,5 +1,7 @@
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin(sort);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {

View File

@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
remove_button: {
title: gettext("Remove"),
},
// biome-ignore lint/style/useNamingConvention: this is required by the api
restore_on_backspace: {},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
@ -103,6 +105,12 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
/**
* A cache of researches that have been made using this input.
* For each record, the key is the user's query and the value
* is the list of results sent back by the server.
*/
protected cache = {} as Record<string, TomOption[]>;
protected abstract valueField: string;
protected abstract labelField: string;
@ -135,7 +143,13 @@ export abstract class AjaxSelect extends AutoCompleteSelectBase {
this.widget.clearOptions();
}
const resp = await this.search(query);
// Check in the cache if this query has already been typed
// and do an actual HTTP request only if the result isn't cached
let resp = this.cache[query];
if (!resp) {
resp = await this.search(query);
this.cache[query] = resp;
}
if (this.filter) {
callback(this.filter(resp), []);

View File

@ -1,3 +1,11 @@
import htmx from "htmx.org";
document.body.addEventListener("htmx:beforeRequest", (event) => {
event.target.ariaBusy = true;
});
document.body.addEventListener("htmx:afterRequest", (event) => {
event.originalTarget.ariaBusy = null;
});
Object.assign(window, { htmx });

View File

@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
// TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost.
/**
* Load complete dataset from paginated routes.
*/
export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
) => {
): Promise<T[]> => {
const maxPerPage = 199;
const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {};

View File

@ -0,0 +1,49 @@
import type { NestedKeyOf } from "#core:utils/types";
interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */
columns: readonly NestedKeyOf<T>[];
/** Content of the first row */
titleRow?: readonly string[];
}
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
let res = obj[path.shift() as keyof T];
for (const node of path) {
if (res === null) {
break;
}
res = res[node];
}
return res;
}
/**
* Convert the content the string to make sure it won't break
* the resulting csv.
* cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules
*/
function sanitizeCell(content: string): string {
return `"${content.replace(/"/g, '""')}"`;
}
export const csv = {
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
const columns = options.columns;
const content = objs
.map((obj) => {
return columns
.map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString());
})
.join(",");
})
.join("\n");
if (!options.titleRow) {
return content;
}
const firstRow = options.titleRow.map(sanitizeCell).join(",");
return `${firstRow}\n${content}`;
},
};

37
core/static/bundled/utils/types.d.ts vendored Normal file
View File

@ -0,0 +1,37 @@
/**
* A key of an object, or of one of its descendants.
*
* Example :
* ```typescript
* interface Foo {
* foo_inner: number;
* }
*
* interface Bar {
* foo: Foo;
* }
*
* const foo = (key: NestedKeyOf<Bar>) {
* console.log(key);
* }
*
* foo("foo.foo_inner"); // OK
* foo("foo.bar"); // FAIL
* ```
*/
export type NestedKeyOf<T extends object> = {
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<T[Key], `${Key}`>;
}[keyof T & (string | number)];
type NestedKeyOfInner<T extends object> = {
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<
T[Key],
`['${Key}']` | `.${Key}`
>;
}[keyof T & (string | number)];
type NestedKeyOfHandleValue<T, Text extends string> = T extends unknown[]
? Text
: T extends object
? Text | `${Text}${NestedKeyOfInner<T>}`
: Text;

View File

@ -6,7 +6,16 @@
**/
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
return (component: CustomElementConstructor) => {
try {
window.customElements.define(name, component, options);
} catch (e) {
if (e instanceof DOMException) {
// biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
console.warn(e.message);
return;
}
throw e;
}
};
}

View File

@ -24,9 +24,17 @@ $black-color: hsl(0, 0%, 17%);
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);
$discordblurple: #7289da;
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
$githubblack: rgb(22, 22, 20);
$shadow-color: rgb(223, 223, 223);
$background-button-color: hsl(0, 0%, 95%);
$deepblue: #354a5f;
@mixin shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
}

View File

@ -0,0 +1,96 @@
@import "core/static/core/colors";
@mixin row-layout {
min-height: 100px;
width: 100%;
max-width: 100%;
display: flex;
flex-direction: row;
gap: 10px;
.card-image {
max-width: 75px;
}
.card-content {
flex: 1;
text-align: left;
}
}
.card {
background-color: $primary-neutral-light-color;
border-radius: 5px;
position: relative;
box-sizing: border-box;
padding: 20px 10px;
height: fit-content;
width: 150px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
&.clickable:hover {
background-color: darken($primary-neutral-light-color, 5%);
}
&.selected {
animation: bg-in-out 1s ease;
background-color: rgb(216, 236, 255);
}
.card-image {
width: 100%;
height: 100%;
min-height: 70px;
max-height: 70px;
object-fit: contain;
border-radius: 4px;
line-height: 70px;
}
i.card-image {
color: black;
text-align: center;
background-color: rgba(173, 173, 173, 0.2);
width: 80%;
}
.card-content {
color: black;
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
p {
font-size: 13px;
margin: 0;
}
.card-title {
margin: 0;
font-size: 15px;
word-break: break-word;
}
}
@keyframes bg-in-out {
0% {
background-color: white;
}
100% {
background-color: rgb(216, 236, 255);
}
}
@media screen and (max-width: 765px) {
@include row-layout
}
// When combined with card, card-row display the card in a row layout,
// whatever the size of the screen.
&.card-row {
@include row-layout
}
}

View File

@ -0,0 +1,5 @@
/*--------------------------MEDIA QUERY HELPERS------------------------*/
$small-devices: 576px;
$medium-devices: 768px;
$large-devices: 992px;

722
core/static/core/forms.scss Normal file
View File

@ -0,0 +1,722 @@
@import "colors";
/**
* Style related to forms and form inputs
*/
/**
* Inputs that are not enclosed in a form element.
*/
:not(form) {
a.button,
button,
input[type="button"],
input[type="submit"],
input[type="reset"],
input[type="file"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
border-radius: 5px;
color: black;
&:hover {
background: hsl(0, 0%, 83%);
}
}
a.button,
input[type="button"],
input[type="submit"],
input[type="reset"],
input[type="file"] {
font-weight: bold;
}
a.button:not(:disabled),
button:not(:disabled),
input[type="button"]:not(:disabled),
input[type="submit"]:not(:disabled),
input[type="reset"]:not(:disabled),
input[type="checkbox"]:not(:disabled),
input[type="file"]:not(:disabled) {
cursor: pointer;
}
input,
textarea[type="text"],
[type="number"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
border-radius: 5px;
max-width: 95%;
}
textarea {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 7px;
font-size: 1.2em;
border-radius: 5px;
font-family: sans-serif;
}
select {
border: none;
text-decoration: none;
font-size: 1.2em;
background-color: $background-button-color;
padding: 10px;
border-radius: 5px;
cursor: pointer;
}
a:not(.button) {
text-decoration: none;
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&:active {
color: $primary-color;
}
}
}
form {
// Input size - used for height/padding calculations
--nf-input-size: 1rem;
--nf-input-font-size: calc(var(--nf-input-size) * 0.875);
--nf-small-font-size: calc(var(--nf-input-size) * 0.875);
// Input
--nf-input-color: $text-color;
--nf-input-border-radius: 0.25rem;
--nf-input-placeholder-color: #929292;
--nf-input-border-color: #c0c4c9;
--nf-input-border-width: 1px;
--nf-input-border-style: solid;
--nf-input-border-bottom-width: 2px;
--nf-input-focus-border-color: #3b4ce2;
--nf-input-background-color: #f3f6f7;
// Valid/invalid
--nf-invalid-input-border-color: var(--nf-input-border-color);
--nf-invalid-input-background-color: var(--nf-input-background-color);
--nf-invalid-input-color: var(--nf-input-color);
--nf-valid-input-border-color: var(--nf-input-border-color);
--nf-valid-input-background-color: var(--nf-input-background-color);
--nf-valid-input-color: inherit;
--nf-invalid-input-border-bottom-color: red;
--nf-valid-input-border-bottom-color: green;
// Label variables
--nf-label-font-size: var(--nf-small-font-size);
--nf-label-color: #374151;
--nf-label-font-weight: 500;
// Slider variables
--nf-slider-track-background: #dfdfdf;
--nf-slider-track-height: 0.25rem;
--nf-slider-thumb-size: calc(var(--nf-slider-track-height) * 4);
--nf-slider-track-border-radius: var(--nf-slider-track-height);
--nf-slider-thumb-border-width: 2px;
--nf-slider-thumb-border-focus-width: 1px;
--nf-slider-thumb-border-color: #ffffff;
--nf-slider-thumb-background: var(--nf-input-focus-border-color);
display: block;
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
line-height: 1;
white-space: nowrap;
.helptext {
margin-top: .25rem;
margin-bottom: .25rem;
font-size: 80%;
display: block;
}
fieldset {
margin-bottom: 1rem;
}
.row {
label {
margin: unset;
}
}
// ------------- LABEL
label, legend {
font-weight: var(--nf-label-font-weight);
display: block;
margin-bottom: calc(var(--nf-input-size) / 2);
white-space: initial;
+ small {
font-style: initial;
}
&.required:after {
margin-left: 4px;
content: "*";
color: red;
}
}
// wrap texts
label, legend, ul.errorlist>li, .helptext {
text-wrap: wrap;
}
.choose_file_widget {
display: none;
}
// ------------- SMALL
small {
display: block;
font-weight: normal;
opacity: 0.75;
font-size: var(--nf-small-font-size);
margin-bottom: calc(var(--nf-input-size) * 0.75);
&:last-child {
margin-bottom: 0;
}
}
.form-group,
> p,
> div {
margin-top: calc(var(--nf-input-size) / 2);
}
// ------------ ERROR LIST
ul.errorlist {
list-style-type: none;
margin: 0;
opacity: 60%;
color: var(--nf-invalid-input-border-bottom-color);
> li {
text-align: left;
margin-top: 5px;
}
}
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="number"],
input[type="date"],
input[type="week"],
input[type="time"],
input[type="month"],
input[type="search"],
textarea,
select {
min-width: 300px;
&.grow {
width: 95%;
}
}
input[type="text"],
input[type="checkbox"],
input[type="radio"],
input[type="email"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="number"],
input[type="date"],
input[type="datetime-local"],
input[type="week"],
input[type="time"],
input[type="month"],
input[type="search"],
textarea,
select {
background: var(--nf-input-background-color);
font-size: var(--nf-input-font-size);
border-color: var(--nf-input-border-color);
border-width: var(--nf-input-border-width);
border-style: var(--nf-input-border-style);
box-shadow: none;
border-radius: var(--nf-input-border-radius);
border-bottom-width: var(--nf-input-border-bottom-width);
color: var(--nf-input-color);
max-width: 95%;
box-sizing: border-box;
padding: calc(var(--nf-input-size) * 0.65);
line-height: normal;
appearance: none;
transition: all 0.15s ease-out;
// ------------- VALID/INVALID
&.error {
&:not(:placeholder-shown):invalid {
background-color: var(--nf-invalid-input-background-color);
border-color: var(--nf-valid-input-border-color);
border-bottom-color: var(--nf-invalid-input-border-bottom-color);
color: var(--nf-invalid-input-color);
// Reset to default when focus
&:focus {
background-color: var(--nf-input-background-color);
border-color: var(--nf-input-border-color);
color: var(--nf-input-color);
}
}
&:not(:placeholder-shown):valid {
background-color: var(--nf-valid-input-background-color);
border-color: var(--nf-valid-input-border-color);
border-bottom-color: var(--nf-valid-input-border-bottom-color);
color: var(--nf-valid-input-color);
}
}
// ------------- DISABLED
&:disabled {
cursor: not-allowed;
opacity: 0.75;
}
// -------- PLACEHOLDERS
&::-webkit-input-placeholder {
color: var(--nf-input-placeholder-color);
letter-spacing: 0;
}
&:-ms-input-placeholder {
color: var(--nf-input-placeholder-color);
letter-spacing: 0;
}
&::-moz-placeholder {
color: var(--nf-input-placeholder-color);
letter-spacing: 0;
}
&:-moz-placeholder {
color: var(--nf-input-placeholder-color);
letter-spacing: 0;
}
// -------- FOCUS
&:focus {
outline: none;
border-color: var(--nf-input-focus-border-color);
}
// -------- ADDITIONAL TEXT BENEATH INPUT FIELDS
+ small {
margin-top: 0.5rem;
}
// -------- ICONS
--icon-padding: calc(var(--nf-input-size) * 2.25);
--icon-background-offset: calc(var(--nf-input-size) * 0.75);
&.icon-left {
background-position: left var(--icon-background-offset) bottom 50%;
padding-left: var(--icon-padding);
background-size: var(--nf-input-size);
}
&.icon-right {
background-position: right var(--icon-background-offset) bottom 50%;
padding-right: var(--icon-padding);
background-size: var(--nf-input-size);
}
// When a field has a icon and is autofilled, the background image is removed
// by the browser. To negate this we reset the padding, not great but okay
&:-webkit-autofill {
padding: calc(var(--nf-input-size) * 0.75) !important;
}
}
// -------- SEARCH
input[type="search"] {
&:placeholder-shown {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
background-position: left calc(var(--nf-input-size) * 0.75) bottom 50%;
padding-left: calc(var(--nf-input-size) * 2.25);
background-size: var(--nf-input-size);
background-repeat: no-repeat;
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
width: var(--nf-input-size);
height: var(--nf-input-size);
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
}
&:focus {
padding-left: calc(var(--nf-input-size) * 0.75);
background-position: left calc(var(--nf-input-size) * -1) bottom 50%;
}
}
// -------- EMAIL
input[type="email"][class^="icon"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-at-sign'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94'/%3E%3C/svg%3E");
background-repeat: no-repeat;
}
// -------- TEL
input[type="tel"][class^="icon"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-phone'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
}
// -------- URL
input[type="url"][class^="icon"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-link'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E");
background-repeat: no-repeat;
}
// -------- PASSWORD
input[type="password"] {
letter-spacing: 2px;
&[class^="icon"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-lock'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
}
}
// -------- RANGE
input[type="range"] {
-webkit-appearance: none;
width: 100%;
cursor: pointer;
&:focus {
outline: none;
}
// NOTE: for some reason grouping these doesn't work (just like :placeholder)
@mixin track {
width: 100%;
height: var(--nf-slider-track-height);
background: var(--nf-slider-track-background);
border-radius: var(--nf-slider-track-border-radius);
}
@mixin thumb {
height: var(--nf-slider-thumb-size);
width: var(--nf-slider-thumb-size);
border-radius: var(--nf-slider-thumb-size);
background: var(--nf-slider-thumb-background);
border: 0;
border: var(--nf-slider-thumb-border-width) solid var(--nf-slider-thumb-border-color);
appearance: none;
}
@mixin thumb-focus {
box-shadow: 0 0 0 var(--nf-slider-thumb-border-focus-width) var(--nf-slider-thumb-background);
}
&::-webkit-slider-runnable-track {
@include track;
}
&::-moz-range-track {
@include track;
}
&::-webkit-slider-thumb {
@include thumb;
margin-top: calc(
(
calc(var(--nf-slider-track-height) - var(--nf-slider-thumb-size)) *
0.5
)
);
}
&::-moz-range-thumb {
@include thumb;
box-sizing: border-box;
}
&:focus::-webkit-slider-thumb {
@include thumb-focus;
}
&:focus::-moz-range-thumb {
@include thumb-focus;
}
}
// -------- COLOR
input[type="color"] {
border: var(--nf-input-border-width) solid var(--nf-input-border-color);
border-bottom-width: var(--nf-input-border-bottom-width);
height: calc(var(--nf-input-size) * 2);
border-radius: var(--nf-input-border-radius);
padding: calc(var(--nf-input-border-width) * 2);
&:focus {
outline: none;
border-color: var(--nf-input-focus-border-color);
}
&::-webkit-color-swatch-wrapper {
padding: 5%;
}
@mixin swatch {
border-radius: calc(var(--nf-input-border-radius) / 2);
border: none;
}
&::-moz-color-swatch {
@include swatch;
}
&::-webkit-color-swatch {
@include swatch;
}
}
// --------------- NUMBER
input[type="number"] {
width: auto;
}
// --------------- DATES
input[type="date"],
input[type="datetime-local"],
input[type="week"],
input[type="month"] {
min-width: 300px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-calendar'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
}
input[type="time"] {
min-width: 6em;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-clock'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E");
}
input[type="date"],
input[type="datetime-local"],
input[type="week"],
input[type="time"],
input[type="month"] {
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
background-repeat: no-repeat;
background-size: var(--nf-input-size);
&::-webkit-inner-spin-button,
&::-webkit-calendar-picker-indicator {
-webkit-appearance: none;
cursor: pointer;
opacity: 0;
}
// FireFox reset
// FF has restricted control of styling the date/time inputs.
// That's why we don't show icons for FF users, and leave basic styling in place.
@-moz-document url-prefix() {
min-width: auto;
width: auto;
background-image: none;
}
}
// --------------- TEXAREA
textarea {
height: auto;
}
// --------------- CHECKBOX/RADIO
input[type="checkbox"],
input[type="radio"] {
width: var(--nf-input-size);
height: var(--nf-input-size);
padding: inherit;
margin: 0;
display: inline-block;
vertical-align: top;
border-radius: calc(var(--nf-input-border-radius) / 2);
border-width: var(--nf-input-border-width);
cursor: pointer;
background-position: center center;
&:focus:not(:checked) {
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
outline: none;
}
&:hover {
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
}
+ label {
display: inline-block;
margin-bottom: 0;
padding-left: calc(var(--nf-input-size) / 2.5);
font-weight: normal;
user-select: none;
cursor: pointer;
max-width: calc(100% - calc(var(--nf-input-size) * 2));
line-height: normal;
> small {
margin-top: calc(var(--nf-input-size) / 4);
}
}
}
input[type="checkbox"] {
&:checked {
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23FFFFFF' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-check'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center center/85%;
background-color: var(--nf-input-focus-border-color);
border-color: var(--nf-input-focus-border-color);
}
}
input[type="radio"] {
border-radius: 100%;
&:checked {
background-color: var(--nf-input-focus-border-color);
border-color: var(--nf-input-focus-border-color);
box-shadow: 0 0 0 3px white inset;
}
}
// --------------- SWITCH
--switch-orb-size: var(--nf-input-size);
--switch-orb-offset: calc(var(--nf-input-border-width) * 2);
--switch-width: calc(var(--nf-input-size) * 2.5);
--switch-height: calc(
calc(var(--nf-input-size) * 1.25) + var(--switch-orb-offset)
);
input[type="checkbox"].switch {
width: var(--switch-width);
height: var(--switch-height);
border-radius: var(--switch-height);
position: relative;
&::after {
background: var(--nf-input-border-color);
border-radius: var(--switch-orb-size);
height: var(--switch-orb-size);
left: var(--switch-orb-offset);
position: absolute;
top: 50%;
transform: translateY(-50%);
width: var(--switch-orb-size);
content: "";
transition: all 0.2s ease-out;
}
+ label {
margin-top: calc(var(--switch-height) / 8);
}
&:checked {
background: var(--nf-input-focus-border-color) none initial;
&::after {
transform: translateY(-50%) translateX(
calc(calc(var(--switch-width) / 2) - var(--switch-orb-offset))
);
background: white;
}
}
}
// ---------------- FILE
input[type="file"] {
background: rgba(0, 0, 0, 0.025);
padding: calc(var(--nf-input-size) / 2);
display: block;
font-weight: normal;
width: 95%;
box-sizing: border-box;
border-radius: var(--nf-input-border-radius);
border: 1px dashed var(--nf-input-border-color);
outline: none;
cursor: pointer;
&:focus,
&:hover {
border-color: var(--nf-input-focus-border-color);
}
@mixin button {
background: var(--nf-input-focus-border-color);
border: 0;
appearance: none;
border-radius: var(--nf-input-border-radius);
color: white;
margin-right: 0.75rem;
outline: none;
cursor: pointer;
}
&::file-selector-button {
@include button();
}
&::-webkit-file-upload-button {
@include button();
}
}
// ---------------- SELECT
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
background-repeat: no-repeat;
background-size: var(--nf-input-size);
}
}

View File

@ -34,7 +34,7 @@ $hovered-red-text-color: #ff4d4d;
gap: 10px;
> a {
color: $text-color;
color: $text-color!important;
}
&:hover>a {
@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
}
>input[type=text] {
box-sizing: border-box;
max-width: 100%;
width: 100%;
min-width: unset;
border: unset;
height: 35px;
border-radius: 5px;
font-size: .9em;

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
@import "core/static/core/colors";
main {
box-sizing: border-box;
display: flex;
@ -69,7 +71,7 @@ main {
border-radius: 50%;
justify-content: center;
align-items: center;
background-color: #f2f2f2;
background-color: $primary-neutral-light-color;
> span {
font-size: small;

View File

@ -1,26 +1,9 @@
@media (max-width: 750px) {
.title {
text-align: center;
}
}
.field-error {
height: auto !important;
> ul {
list-style-type: none;
margin: 0;
color: indianred;
> li {
text-align: left !important;
line-height: normal;
margin-top: 5px;
}
}
}
.profile {
&-visible {
display: flex;
@ -87,10 +70,6 @@
max-height: 100%;
}
> i {
font-size: 32px;
}
> p {
text-align: left !important;
width: 100% !important;
@ -107,16 +86,6 @@
> div {
max-width: 100%;
> input {
font-weight: normal;
cursor: pointer;
text-align: left !important;
}
> button {
min-width: 30%;
}
@media (min-width: 750px) {
height: auto;
align-items: center;
@ -124,8 +93,8 @@
overflow: hidden;
> input {
width: 70%;
font-size: .6em;
&::file-selector-button {
height: 30px;
}
@ -181,45 +150,22 @@
}
&-content {
> * {
box-sizing: border-box;
text-align: left !important;
line-height: 40px;
max-width: 100%;
width: 100%;
height: 40px;
margin: 0;
> * {
text-align: left !important;
}
}
>textarea {
height: 120px;
min-height: 40px;
min-width: 300px;
max-width: 300px;
line-height: initial;
@media (max-width: 750px) {
max-width: 100%;
}
}
>input[type="file"] {
font-size: small;
line-height: 30px;
}
>input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 0;
float: left;
textarea {
height: 7rem;
}
.final-actions {
text-align: center;
}
}
}

View File

@ -108,7 +108,8 @@
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</div>
<a href="https://discord.gg/XK9WfPsUFm" target="_link">
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
{% endblock %}

View File

@ -60,13 +60,18 @@
{% endif %}
{% if user.date_of_birth %}
<div class="user_mini_profile_dob">
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }})
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
</div>
{% endif %}
</div>
{% if user.promo and user.promo_has_logo() %}
<div class="user_mini_profile_promo">
<img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
<img
src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
title="Promo {{ user.promo }}"
alt="Promo {{ user.promo }}"
class="promo_pict"
/>
</div>
{% endif %}
</div>
@ -74,8 +79,11 @@
{% if user.profile_pict %}
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
{% else %}
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
title="{% trans %}Profile{% endtrans %}" />
<img
src="{{ static('core/img/unknown.jpg') }}"
alt="{% trans %}Profile{% endtrans %}"
title="{% trans %}Profile{% endtrans %}"
/>
{% endif %}
</div>
</div>
@ -132,7 +140,7 @@
nb_page (str): call to a javascript function or variable returning
the maximum number of pages to paginate
#}
<nav class="pagination" x-show="{{ nb_pages }} > 1">
<nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
{# Adding the prevent here is important, because otherwise,
clicking on the pagination buttons could submit the picture management form
and reload the page #}
@ -170,12 +178,12 @@
{% endmacro %}
{% macro paginate_htmx(current_page, paginator) %}
{# Add pagination buttons for pages without Alpine but supporting framgents.
{# Add pagination buttons for pages without Alpine but supporting fragments.
This must be coupled with a view that handles pagination
with the Django Paginator object and supports framgents.
with the Django Paginator object and supports fragments.
The relpaced fragment will be #content so make sure you are calling this macro inside your content block.
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
Parameters:
current_page (django.core.paginator.Page): the current page object
@ -247,9 +255,9 @@
{% macro select_all_checkbox(form_id) %}
<script type="text/javascript">
function checkbox_{{form_id}}(value) {
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
for (let element of list){
if (element.type == "checkbox"){
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
for (let element of inputs){
if (element.type === "checkbox"){
element.checked = value;
}
}
@ -258,3 +266,65 @@
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %}
{% macro tabs(tab_list, attrs = "") %}
{# Tab component
Parameters:
tab_list: list[tuple[str, str]] The list of tabs to display.
Each element of the list is a tuple which first element
is the title of the tab and the second element its content
attrs: str Additional attributes to put on the enclosing div
Example:
A basic usage would be as follow :
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
If you want to display more complex logic, you can define macros
and use those macros in parameters :
{{ tabs([("title", my_macro())]) }}
It's also possible to get and set the currently selected tab using Alpine.
Here, the title of the currently selected tab will be displayed.
Moreover, on page load, the tab will be opened on "tab 2".
<div x-data="{current_tab: 'tab 2'}">
<p x-text="current_tab"></p>
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
</div>
If you want to have translated tab titles, you can enclose the macro call
in a with block :
{% with title=_("title"), content=_("Content") %}
{{ tabs([(tab1, content)]) }}
{% endwith %}
#}
<div
class="tabs shadow"
x-data="{selected: '{{ tab_list[0][0] }}'}"
x-modelable="selected"
{{ attrs }}
>
<div class="tab-headers">
{% for title, _ in tab_list %}
<button
class="tab-header clickable"
:class="{active: selected === '{{ title }}'}"
@click="selected = '{{ title }}'"
>
{{ title }}
</button>
{% endfor %}
</div>
<div class="tab-content">
{% for title, content in tab_list %}
<section x-show="selected === '{{ title }}'">
{{ content }}
</section>
{% endfor %}
</div>
</div>
{% endmacro %}

View File

@ -3,17 +3,18 @@
{% macro page_history(page) %}
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
<ul>
{% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
{% if loop.index < 2 %}
<li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
{{ user_profile_link(page.revisions.last().author) }} -
{{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
{% set page_name = page.get_full_name() %}
{%- for rev in page.revisions.order_by("-date").select_related("author") -%}
<li>
{% if loop.first %}
<a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
{% else %}
<li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
{{ user_profile_link(r.author) }} -
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
<a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
{% endif %}
{% endfor %}
{{ user_profile_link(rev.author) }} -
{{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
</li>
{%- endfor -%}
</ul>
{% endmacro %}

View File

@ -1,54 +0,0 @@
{% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block title %}
{% trans %}Poster{% endtrans %}
{% endblock %}
{% block content %}
<div id="poster_list">
<div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3>
<div id="links" class="right">
<a id="create" class="link" href="{{ url(app + ":poster_list") }}">{% trans %}Create{% endtrans %}</a>
{% if app == "com" %}
<a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
{% endif %}
</div>
</div>
<div id="posters">
{% if poster_list.count() == 0 %}
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
{% else %}
{% for poster in poster_list %}
<div class="poster">
<div class="name">{{ poster.name }}</div>
<div class="image"><img src="{{ poster.file.url }}"></img></div>
<div class="dates">
<div class="begin">{{ poster.date_begin | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | date("d/M/Y H:m") }}</div>
</div>
<a class="edit" href="{{ url(poster_edit_url_name, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
</div>
{% endfor %}
{% endif %}
</div>
<div id="view"><div id="placeholder"></div></div>
</div>
{% endblock %}

View File

@ -63,9 +63,7 @@
{%- trans -%}Delete{%- endtrans -%}
</button>
</div>
<p>
{{ form[field_name].label }}
</p>
{{ form[field_name].label_tag() }}
{{ form[field_name].errors }}
{%- else -%}
<em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
@ -118,9 +116,7 @@
{# All fields #}
<div class="profile-fields">
{%- for field in form -%}
{%-
if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"]
-%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
{%- continue -%}
{%- endif -%}
@ -156,6 +152,7 @@
{{ form.is_subscriber_viewable }}
{{ form.is_subscriber_viewable.label }}
</div>
<div class="final-actions">
{%- if form.instance == user -%}
<p>
@ -172,6 +169,7 @@
<p>
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
</p>
</div>
</form>
<p>

View File

@ -28,42 +28,20 @@
</form>
{% else %}
<p>{% trans trombi=user.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<br />
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
</p>
{% endif %}
{% if profile.customer %}
<h3>{% trans %}Student cards{% endtrans %}</h3>
{% if profile.customer.student_cards.exists() %}
<ul class="student-cards">
{% for card in profile.customer.student_cards.all() %}
<li>
{{ card.uid }}
&nbsp;-&nbsp;
<a href="{{ url('counter:delete_student_card', customer_id=profile.customer.pk, card_id=card.id) }}">
{% trans %}Delete{% endtrans %}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em>
{% if student_card_fragment %}
<h3>{% trans %}Student card{% endtrans %}</h3>
{{ student_card_fragment }}
<p class="justify">
{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
</p>
{% endif %}
<form class="form form-cards" action="{{ url('counter:add_student_card', customer_id=profile.customer.pk) }}"
method="post">
{% csrf_token %}
{{ student_card_form.as_p() }}
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
{% endif %}
</div>
{% endblock %}

View File

@ -23,6 +23,9 @@
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
{% endif %}
{% if user.has_perm("core:view_userban") %}
<li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
{% endif %}
{% if user.can_create_subscription or user.is_root %}
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
{% endif %}
@ -52,7 +55,7 @@
%}
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
<li><a href="{{ url('counter:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
<li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>

View File

@ -18,6 +18,7 @@ from smtplib import SMTPException
import freezegun
import pytest
from django.contrib.auth.hashers import make_password
from django.core import mail
from django.core.cache import cache
from django.core.mail import EmailMessage
@ -30,7 +31,7 @@ from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Membership
from club.models import Club, Membership
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester
@ -118,7 +119,9 @@ class TestUserRegistration:
response = client.post(reverse("core:register"), valid_payload)
assert response.status_code == 200
error_html = "<li>Un objet User avec ce champ Adresse email existe déjà.</li>"
error_html = (
"<li>Un objet Utilisateur avec ce champ Adresse email existe déjà.</li>"
)
assertInHTML(error_html, str(response.content.decode()))
def test_register_fail_with_not_existing_email(
@ -143,7 +146,7 @@ class TestUserRegistration:
class TestUserLogin:
@pytest.fixture()
def user(self) -> User:
return User.objects.first()
return baker.make(User, password=make_password("plop"))
def test_login_fail(self, client, user):
"""Should not login a user correctly."""
@ -347,56 +350,35 @@ class TestUserIsInGroup(TestCase):
@classmethod
def setUpTestData(cls):
from club.models import Club
cls.root_group = Group.objects.get(name="Root")
cls.public = Group.objects.get(name="Public")
cls.skia = User.objects.get(username="skia")
cls.toto = User.objects.create(
username="toto", first_name="a", last_name="b", email="a.b@toto.fr"
)
cls.public_group = Group.objects.get(name="Public")
cls.public_user = baker.make(User)
cls.subscribers = Group.objects.get(name="Subscribers")
cls.old_subscribers = Group.objects.get(name="Old subscribers")
cls.accounting_admin = Group.objects.get(name="Accounting admin")
cls.com_admin = Group.objects.get(name="Communication admin")
cls.counter_admin = Group.objects.get(name="Counter admin")
cls.banned_alcohol = Group.objects.get(name="Banned from buying alcohol")
cls.banned_counters = Group.objects.get(name="Banned from counters")
cls.banned_subscription = Group.objects.get(name="Banned to subscribe")
cls.sas_admin = Group.objects.get(name="SAS admin")
cls.club = Club.objects.create(
name="Fake Club",
unix_name="fake-club",
address="Fake address",
)
cls.club = baker.make(Club)
cls.main_club = Club.objects.get(id=1)
def assert_in_public_group(self, user):
assert user.is_in_group(pk=self.public.id)
assert user.is_in_group(name=self.public.name)
def assert_in_club_metagroups(self, user, club):
meta_groups_board = club.unix_name + settings.SITH_BOARD_SUFFIX
meta_groups_members = club.unix_name + settings.SITH_MEMBER_SUFFIX
assert user.is_in_group(name=meta_groups_board) is False
assert user.is_in_group(name=meta_groups_members) is False
assert user.is_in_group(pk=self.public_group.id)
assert user.is_in_group(name=self.public_group.name)
def assert_only_in_public_group(self, user):
self.assert_in_public_group(user)
for group in (
self.root_group,
self.banned_counters,
self.accounting_admin,
self.sas_admin,
self.subscribers,
self.old_subscribers,
self.club.members_group,
self.club.board_group,
):
assert not user.is_in_group(pk=group.pk)
assert not user.is_in_group(name=group.name)
meta_groups_board = self.club.unix_name + settings.SITH_BOARD_SUFFIX
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
assert user.is_in_group(name=meta_groups_board) is False
assert user.is_in_group(name=meta_groups_members) is False
def test_anonymous_user(self):
"""Test that anonymous users are only in the public group."""
@ -405,80 +387,80 @@ class TestUserIsInGroup(TestCase):
def test_not_subscribed_user(self):
"""Test that users who never subscribed are only in the public group."""
self.assert_only_in_public_group(self.toto)
self.assert_only_in_public_group(self.public_user)
def test_wrong_parameter_fail(self):
"""Test that when neither the pk nor the name argument is given,
the function raises a ValueError.
"""
with self.assertRaises(ValueError):
self.toto.is_in_group()
self.public_user.is_in_group()
def test_number_queries(self):
"""Test that the number of db queries is stable
and that less queries are made when making a new call.
"""
# make sure Skia is in at least one group
self.skia.groups.add(Group.objects.first().pk)
skia_groups = self.skia.groups.all()
group_in = baker.make(Group)
self.public_user.groups.add(group_in)
group_in = skia_groups.first()
cache.clear()
# Test when the user is in the group
with self.assertNumQueries(2):
self.skia.is_in_group(pk=group_in.id)
self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0):
self.skia.is_in_group(pk=group_in.id)
self.public_user.is_in_group(pk=group_in.id)
ids = skia_groups.values_list("pk", flat=True)
group_not_in = Group.objects.exclude(pk__in=ids).first()
group_not_in = baker.make(Group)
cache.clear()
# Test when the user is not in the group
with self.assertNumQueries(2):
self.skia.is_in_group(pk=group_not_in.id)
self.public_user.is_in_group(pk=group_not_in.id)
with self.assertNumQueries(0):
self.skia.is_in_group(pk=group_not_in.id)
self.public_user.is_in_group(pk=group_not_in.id)
def test_cache_properly_cleared_membership(self):
"""Test that when the membership of a user end,
the cache is properly invalidated.
"""
membership = Membership.objects.create(
club=self.club, user=self.toto, end_date=None
)
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
membership = baker.make(Membership, club=self.club, user=self.public_user)
cache.clear()
assert self.toto.is_in_group(name=meta_groups_members) is True
assert membership == cache.get(f"membership_{self.club.id}_{self.toto.id}")
self.club.get_membership_for(self.public_user) # this should populate the cache
assert membership == cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
membership.end_date = now() - timedelta(minutes=5)
membership.save()
cached_membership = cache.get(f"membership_{self.club.id}_{self.toto.id}")
cached_membership = cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
assert cached_membership == "not_member"
assert self.toto.is_in_group(name=meta_groups_members) is False
def test_cache_properly_cleared_group(self):
"""Test that when a user is removed from a group,
the is_in_group_method return False when calling it again.
"""
# testing with pk
self.toto.groups.add(self.com_admin.pk)
assert self.toto.is_in_group(pk=self.com_admin.pk) is True
self.public_user.groups.add(self.com_admin.pk)
assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
self.toto.groups.remove(self.com_admin.pk)
assert self.toto.is_in_group(pk=self.com_admin.pk) is False
self.public_user.groups.remove(self.com_admin.pk)
assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
# testing with name
self.toto.groups.add(self.sas_admin.pk)
assert self.toto.is_in_group(name="SAS admin") is True
self.public_user.groups.add(self.sas_admin.pk)
assert self.public_user.is_in_group(name="SAS admin") is True
self.toto.groups.remove(self.sas_admin.pk)
assert self.toto.is_in_group(name="SAS admin") is False
self.public_user.groups.remove(self.sas_admin.pk)
assert self.public_user.is_in_group(name="SAS admin") is False
def test_not_existing_group(self):
"""Test that searching for a not existing group
returns False.
"""
assert self.skia.is_in_group(name="This doesn't exist") is False
user = baker.make(User)
user.groups.set(list(Group.objects.all()))
assert not user.is_in_group(name="This doesn't exist")
class TestDateUtils(TestCase):

View File

@ -14,7 +14,7 @@ from PIL import Image
from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, RealGroup, SithFile, User
from core.models import Group, SithFile, User
from sas.models import Picture
from sith import settings
@ -26,12 +26,10 @@ class TestImageAccess:
[
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
User,
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(
User,
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
],
)

View File

@ -13,22 +13,41 @@
#
#
from dataclasses import dataclass
from datetime import date
# Image utils
from io import BytesIO
from typing import Optional
from typing import Any
import PIL
from django.conf import settings
from django.core.files.base import ContentFile
from django.forms import BaseForm
from django.http import HttpRequest
from django.template.loader import render_to_string
from django.utils.html import SafeString
from django.utils.timezone import localdate
from PIL import ExifTags
from PIL.Image import Image, Resampling
def get_start_of_semester(today: Optional[date] = None) -> date:
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester.
@ -58,7 +77,7 @@ def get_start_of_semester(today: Optional[date] = None) -> date:
return autumn.replace(year=autumn.year - 1)
def get_semester_code(d: Optional[date] = None) -> str:
def get_semester_code(d: date | None = None) -> str:
"""Return the semester code of the given date.
If no date is given, return the semester code of the current semester.

View File

@ -13,6 +13,7 @@
#
#
import mimetypes
from pathlib import Path
from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model
@ -21,6 +22,7 @@ from wsgiref.util import FileWrapper
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef
from django.forms.models import modelform_factory
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
@ -31,7 +33,7 @@ from django.views.generic import DetailView, ListView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
from core.models import Notification, RealGroup, SithFile, User
from core.models import Notification, SithFile, User
from core.views import (
AllowFragment,
CanEditMixin,
@ -47,6 +49,41 @@ from core.views.widgets.select import (
from counter.utils import is_logged_in_counter
def send_raw_file(path: Path) -> HttpResponse:
"""Send a file located in the MEDIA_ROOT
This handles all the logic of using production reverse proxy or debug server.
THIS DOESN'T CHECK ANY PERMISSIONS !
"""
if not path.is_relative_to(settings.MEDIA_ROOT):
raise Http404
if not path.is_file() or not path.exists():
raise Http404
response = HttpResponse(
headers={"Content-Disposition": f'inline; filename="{quote(path.name)}"'}
)
if not settings.DEBUG:
# When receiving a response with the Accel-Redirect header,
# the reverse proxy will automatically handle the file sending.
# This is really hard to test (thus isn't tested)
# so please do not mess with this.
response["Content-Type"] = "" # automatically set by nginx
response["X-Accel-Redirect"] = quote(
urljoin(settings.MEDIA_URL, str(path.relative_to(settings.MEDIA_ROOT)))
)
return response
with open(path, "rb") as filename:
response.content = FileWrapper(filename)
response["Content-Type"] = mimetypes.guess_type(path)[0]
response["Last-Modified"] = http_date(path.stat().st_mtime)
response["Content-Length"] = path.stat().st_size
return response
def send_file(
request: HttpRequest,
file_id: int,
@ -65,28 +102,7 @@ def send_file(
raise PermissionDenied
name = getattr(f, file_attr).name
response = HttpResponse(
headers={"Content-Disposition": f'inline; filename="{quote(name)}"'}
)
if not settings.DEBUG:
# When receiving a response with the Accel-Redirect header,
# the reverse proxy will automatically handle the file sending.
# This is really hard to test (thus isn't tested)
# so please do not mess with this.
response["Content-Type"] = "" # automatically set by nginx
response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name))
return response
filepath = settings.MEDIA_ROOT / name
# check if file exists on disk
if not filepath.exists():
raise Http404
with open(filepath, "rb") as filename:
response.content = FileWrapper(filename)
response["Content-Type"] = mimetypes.guess_type(filepath)[0]
response["Last-Modified"] = http_date(f.date.timestamp())
response["Content-Length"] = filepath.stat().st_size
return response
return send_raw_file(settings.MEDIA_ROOT / name)
class MultipleFileInput(forms.ClearableFileInput):
@ -159,19 +175,18 @@ class AddFilesForm(forms.Form):
% {"file_name": f, "msg": repr(e)},
)
if notif:
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
if not u.notifications.filter(
type="FILE_MODERATION", viewed=False
).exists():
Notification(
user=u,
Notification.objects.create(
user=user,
url=reverse("core:file_moderation"),
type="FILE_MODERATION",
).save()
)
class FileListView(ListView):

View File

@ -21,6 +21,7 @@
#
#
import re
from datetime import date, datetime
from io import BytesIO
from captcha.fields import CaptchaField
@ -37,14 +38,16 @@ from django.forms import (
DateInput,
DateTimeInput,
TextInput,
Widget,
)
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
from antispam.forms import AntiSpamEmailField
from core.models import Gift, Page, SithFile, User
from core.models import Gift, Group, Page, SithFile, User
from core.utils import resize_image
from core.views.widgets.select import (
AutoCompleteSelect,
@ -130,6 +133,23 @@ class SelectUser(TextInput):
return output
# Fields
def validate_future_timestamp(value: date | datetime):
if value <= now():
raise ValueError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField):
"""A datetime field that accepts only future timestamps."""
default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: Widget) -> dict[str, str]:
return {"min": widget.format_value(now())}
# Forms
@ -167,14 +187,15 @@ class RegisteringForm(UserCreationForm):
class Meta:
model = User
fields = ("first_name", "last_name", "email")
field_classes = {
"email": AntiSpamEmailField,
}
field_classes = {"email": AntiSpamEmailField}
class UserProfileForm(forms.ModelForm):
"""Form handling the user profile, managing the files"""
required_css_class = "required"
error_css_class = "error"
class Meta:
model = User
fields = [
@ -287,15 +308,20 @@ class UserProfileForm(forms.ModelForm):
self._post_clean()
class UserPropForm(forms.ModelForm):
class UserGroupsForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
groups = forms.ModelMultipleChoiceField(
queryset=Group.objects.filter(is_manually_manageable=True),
widget=CheckboxSelectMultiple,
label=_("Groups"),
required=False,
)
class Meta:
model = User
fields = ["groups"]
help_texts = {"groups": "Which groups this user belongs to"}
widgets = {"groups": CheckboxSelectMultiple}
class UserGodfathersForm(forms.Form):

View File

@ -21,11 +21,9 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import RealGroup, User
from core.models import Group, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views.widgets.select import (
AutoCompleteSelectMultipleUser,
)
from core.views.widgets.select import AutoCompleteSelectMultipleUser
# Forms
@ -59,7 +57,8 @@ class EditMembersForm(forms.Form):
class GroupListView(CanEditMixin, ListView):
"""Displays the Group list."""
model = RealGroup
model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
ordering = ["name"]
template_name = "core/group_list.jinja"
@ -67,7 +66,8 @@ class GroupListView(CanEditMixin, ListView):
class GroupEditView(CanEditMixin, UpdateView):
"""Edit infos of a Group."""
model = RealGroup
model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
pk_url_kwarg = "group_id"
template_name = "core/group_edit.jinja"
fields = ["name", "description"]
@ -76,7 +76,8 @@ class GroupEditView(CanEditMixin, UpdateView):
class GroupCreateView(CanCreateMixin, CreateView):
"""Add a new Group."""
model = RealGroup
model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
template_name = "core/create.jinja"
fields = ["name", "description"]
@ -86,7 +87,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
Allow adding and removing users from it.
"""
model = RealGroup
model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
form_class = EditMembersForm
pk_url_kwarg = "group_id"
template_name = "core/group_detail.jinja"
@ -120,7 +122,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
class GroupDeleteView(CanEditMixin, DeleteView):
"""Delete a Group."""
model = RealGroup
model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
pk_url_kwarg = "group_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("core:group_list")

View File

@ -64,16 +64,20 @@ class PageView(CanViewMixin, DetailView):
class PageHistView(CanViewMixin, DetailView):
model = Page
template_name = "core/page_hist.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name"
_cached_object: Page | None = None
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object.need_club_redirection:
return redirect("club:club_hist", club_id=self.object.club.id)
return res
page = self.get_object()
if page.need_club_redirection:
return redirect("club:club_hist", club_id=page.club.id)
return super().dispatch(request, *args, **kwargs)
def get_object(self):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_object(self, *args, **kwargs):
if not self._cached_object:
self._cached_object = super().get_object()
return self._cached_object
class PageRevView(CanViewMixin, DetailView):

View File

@ -35,7 +35,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db.models import DateField, QuerySet
from django.db.models.functions import Trunc
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
@ -68,10 +67,11 @@ from core.views.forms import (
LoginForm,
RegisteringForm,
UserGodfathersForm,
UserGroupsForm,
UserProfileForm,
)
from counter.forms import StudentCardForm
from counter.models import Refilling, Selling
from counter.views.student_card import StudentCardFormView
from eboutic.models import Invoice
from subscription.models import Subscription
from trombi.views import UserTrombiForm
@ -559,10 +559,6 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
context_object_name = "profile"
current_tab = "prefs"
def get_object(self, queryset=None):
user = get_object_or_404(User, pk=self.kwargs["user_id"])
return user
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
pref = self.object.preferences
@ -572,13 +568,12 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
if not (
hasattr(self.object, "trombi_user") and self.request.user.trombi_user.trombi
):
if not hasattr(self.object, "trombi_user"):
kwargs["trombi_form"] = UserTrombiForm()
if hasattr(self.object, "customer"):
kwargs["student_card_form"] = StudentCardForm()
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.object.customer
).render(self.request)
return kwargs
@ -588,9 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
model = User
pk_url_kwarg = "user_id"
template_name = "core/user_group.jinja"
form_class = modelform_factory(
User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
)
form_class = UserGroupsForm
context_object_name = "profile"
current_tab = "groups"

View File

@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
@admin.register(ProductType)
class ProductTypeAdmin(admin.ModelAdmin):
list_display = ("name", "priority")
list_display = ("name", "order")
@admin.register(CashRegisterSummary)

View File

@ -12,41 +12,49 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from django.conf import settings
from django.db.models import F
from django.shortcuts import get_object_or_404
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot
from counter.models import Counter, Permanency, Product
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from counter.models import Counter, Product, ProductType
from counter.schemas import (
CounterFilterSchema,
CounterSchema,
PermanencyFilterSchema,
PermanencySchema,
ProductFilterSchema,
ProductSchema,
ProductTypeSchema,
ReorderProductTypeSchema,
SimpleProductSchema,
SimplifiedCounterSchema,
)
IsCounterAdmin = (
IsRoot
| IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
| IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
)
@api_controller("/counter")
class CounterController(ControllerBase):
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
def fetch_all(self):
return Counter.objects.all()
return Counter.objects.annotate_is_open()
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
def fetch_one(self, counter_id: int):
return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
return self.get_object_or_exception(
Counter.objects.annotate_is_open(), pk=counter_id
)
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
def fetch_bars(self):
counters = list(Counter.objects.all().filter(type="BAR"))
counters = list(Counter.objects.annotate_is_open().filter(type="BAR"))
for c in counters:
self.check_object_permissions(c)
return counters
@ -65,33 +73,72 @@ class CounterController(ControllerBase):
class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[ProductSchema],
response=PaginatedResponseSchema[SimpleProductSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_products(self, search: Annotated[str, MinLen(1)]):
return (
Product.objects.filter(
Q(name__icontains=search) | Q(code__icontains=search)
)
.filter(archived=False)
.values()
def search_products(self, filters: Query[ProductFilterSchema]):
return filters.filter(
Product.objects.order_by(
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
).values()
)
@api_controller("/permanency")
class PermanencyController(ControllerBase):
@route.get(
"",
response=PaginatedResponseSchema[PermanencySchema],
permissions=[IsAuthenticated],
exclude_none=True,
"/search/detailed",
response=PaginatedResponseSchema[ProductSchema],
permissions=[IsCounterAdmin],
url_name="search_products_detailed",
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_permanencies(self, filters: Query[PermanencyFilterSchema]):
return (
filters.filter(Permanency.objects.all())
.distinct()
.order_by("-start")
.select_related("counter")
@paginate(PageNumberPaginationExtra, page_size=50)
def search_products_detailed(self, filters: Query[ProductFilterSchema]):
"""Get the detailed information about the products."""
return filters.filter(
Product.objects.select_related("club")
.prefetch_related("buying_groups")
.select_related("product_type")
.order_by(
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
)
)
@api_controller("/product-type", permissions=[IsCounterAdmin])
class ProductTypeController(ControllerBase):
@route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types")
def fetch_all(self):
return ProductType.objects.order_by("order")
@route.patch("/{type_id}/move")
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
"""Change the order of a product type.
To use this route, give either the id of the product type
this one should be above of,
of the id of the product type this one should be below of.
Order affects the display order of the product types.
Examples:
```
GET /api/counter/product-type
=> [<1: type A>, <2: type B>, <3: type C>]
PATCH /api/counter/product-type/3/move?below=1
GET /api/counter/product-type
=> [<1: type A>, <3: type C>, <2: type B>]
```
"""
product_type: ProductType = self.get_object_or_exception(
ProductType, pk=type_id
)
other = get_object_or_404(ProductType, pk=other_id.above or other_id.below)
if other_id.below is not None:
product_type.below(other)
else:
product_type.above(other)

View File

@ -24,6 +24,12 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
class CounterConfig(AppConfig):
name = "counter"

View File

@ -45,16 +45,14 @@ class BillingInfoForm(forms.ModelForm):
class StudentCardForm(forms.ModelForm):
"""Form for adding student cards
Only used for user profile since CounterClick is to complicated.
"""
"""Form for adding student cards"""
error_css_class = "error"
class Meta:
model = StudentCard
fields = ["uid"]
widgets = {
"uid": NFCTextInput,
}
widgets = {"uid": NFCTextInput}
def clean(self):
cleaned_data = super().clean()
@ -91,7 +89,7 @@ class GetUserForm(forms.Form):
def clean(self):
cleaned_data = super().clean()
cus = None
customer = None
if cleaned_data["code"] != "":
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = (
@ -100,29 +98,24 @@ class GetUserForm(forms.Form):
.first()
)
if card is not None:
cus = card.customer
if cus is None:
cus = Customer.objects.filter(
customer = card.customer
if customer is None:
customer = Customer.objects.filter(
account_id__iexact=cleaned_data["code"]
).first()
elif cleaned_data["id"] is not None:
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
if cus is None or not cus.can_buy:
elif cleaned_data["id"]:
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = cus.user.id
cleaned_data["user"] = cus.user
cleaned_data["user_id"] = customer.user.id
cleaned_data["user"] = customer.user
return cleaned_data
class NFCCardForm(forms.Form):
student_card_uid = forms.CharField(
max_length=StudentCard.UID_SIZE,
required=False,
widget=NFCTextInput,
)
class RefillForm(forms.ModelForm):
allowed_refilling_methods = ["CASH", "CARD"]
error_css_class = "error"
required_css_class = "required"
amount = forms.FloatField(
@ -132,6 +125,21 @@ class RefillForm(forms.ModelForm):
class Meta:
model = Refilling
fields = ["amount", "payment_method", "bank"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["payment_method"].choices = (
method
for method in self.fields["payment_method"].choices
if method[0] in self.allowed_refilling_methods
)
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
if "CHECK" not in self.allowed_refilling_methods:
del self.fields["bank"]
class CounterEditForm(forms.ModelForm):
@ -146,6 +154,9 @@ class CounterEditForm(forms.ModelForm):
class ProductEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = Product
fields = [
@ -153,7 +164,6 @@ class ProductEditForm(forms.ModelForm):
"description",
"product_type",
"code",
"parent_product",
"buying_groups",
"purchase_price",
"selling_price",
@ -164,8 +174,13 @@ class ProductEditForm(forms.ModelForm):
"tray",
"archived",
]
help_texts = {
"description": _(
"Describe the product. If it's an event's click, "
"give some insights about it, like the date (including the year)."
)
}
widgets = {
"parent_product": AutoCompleteSelectMultipleProduct,
"product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub,

View File

@ -55,7 +55,9 @@ class Command(BaseCommand):
customer__user__in=reactivated_users
).delete()
self._dump_accounts({u.customer for u in users_to_dump})
self._send_mails(users_to_dump)
self.stdout.write("Accounts dumped")
nb_successful_mails = self._send_mails(users_to_dump)
self.stdout.write(f"{nb_successful_mails} were successfuly sent.")
self.stdout.write("Finished !")
@staticmethod
@ -103,13 +105,14 @@ class Command(BaseCommand):
if len(pending_dumps) != len(customer_ids):
raise ValueError("One or more accounts were not engaged in a dump process")
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
seller = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
sales = Selling.objects.bulk_create(
[
Selling(
label="Vidange compte inactif",
club=counter.club,
counter=counter,
seller=None,
seller=seller,
product=None,
customer=account,
quantity=1,
@ -124,7 +127,7 @@ class Command(BaseCommand):
# dumps and sales are linked to the same customers
# and or both ordered with the same key, so zipping them is valid
for dump, sale in zip(pending_dumps, sales):
for dump, sale in zip(pending_dumps, sales, strict=False):
dump.dump_operation = sale
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
@ -134,8 +137,12 @@ class Command(BaseCommand):
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
@staticmethod
def _send_mails(users: Iterable[User]):
"""Send the mails informing users that their account has been dumped."""
def _send_mails(users: Iterable[User]) -> int:
"""Send the mails informing users that their account has been dumped.
Returns:
The number of emails successfully sent.
"""
mails = [
(
_("Your AE account has been emptied"),
@ -145,4 +152,4 @@ class Command(BaseCommand):
)
for user in users
]
send_mass_mail(mails)
return send_mass_mail(mails, fail_silently=True)

View File

@ -1,38 +1,6 @@
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
from django.utils.translation import gettext_lazy as _
from core.models import User
from counter.models import Counter, Customer, Product, Selling
def balance_ecocups(apps, schema_editor):
for customer in Customer.objects.all():
customer.recorded_products = 0
for selling in customer.buyings.filter(
product__id__in=[settings.SITH_ECOCUP_CONS, settings.SITH_ECOCUP_DECO]
).all():
if selling.product.is_record_product:
customer.recorded_products += selling.quantity
elif selling.product.is_unrecord_product:
customer.recorded_products -= selling.quantity
if customer.recorded_products < -settings.SITH_ECOCUP_LIMIT:
qt = -(customer.recorded_products + settings.SITH_ECOCUP_LIMIT)
cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
Selling(
label=_("Ecocup regularization"),
product=cons,
unit_price=cons.selling_price,
club=cons.club,
counter=Counter.objects.filter(name="Foyer").first(),
quantity=qt,
seller=User.objects.get(id=0),
customer=customer,
).save(allow_negative=True)
customer.recorded_products += qt
customer.save()
class Migration(migrations.Migration):
@ -44,5 +12,4 @@ class Migration(migrations.Migration):
name="recorded_products",
field=models.IntegerField(verbose_name="recorded items", default=0),
),
migrations.RunPython(balance_ecocups),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.17 on 2024-12-09 11:07
from django.db import migrations, models
import accounting.models
class Migration(migrations.Migration):
dependencies = [("counter", "0024_accountdump_accountdump_unique_ongoing_dump")]
operations = [
migrations.RemoveField(model_name="product", name="parent_product"),
migrations.AlterField(
model_name="product",
name="description",
field=models.TextField(default="", verbose_name="description"),
),
migrations.AlterField(
model_name="product",
name="purchase_price",
field=accounting.models.CurrencyField(
decimal_places=2,
help_text="Initial cost of purchasing the product",
max_digits=12,
verbose_name="purchase price",
),
),
migrations.AlterField(
model_name="product",
name="special_selling_price",
field=accounting.models.CurrencyField(
decimal_places=2,
help_text="Price for barmen during their permanence",
max_digits=12,
verbose_name="special selling price",
),
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.17 on 2024-12-08 13:30
from operator import attrgetter
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Count
def delete_duplicates(apps: StateApps, schema_editor):
"""Delete cards of users with more than one student cards.
For all users who have more than one registered student card, all
the cards except the last one are deleted.
"""
Customer = apps.get_model("counter", "Customer")
StudentCard = apps.get_model("counter", "StudentCard")
customers = (
Customer.objects.annotate(nb_cards=Count("student_cards"))
.filter(nb_cards__gt=1)
.prefetch_related("student_cards")
)
to_delete = [
card.id
for customer in customers
for card in sorted(customer.student_cards.all(), key=attrgetter("id"))[:-1]
]
StudentCard.objects.filter(id__in=to_delete).delete()
class Migration(migrations.Migration):
dependencies = [("counter", "0025_remove_product_parent_product_and_more")]
operations = [
migrations.RunPython(delete_duplicates, migrations.RunPython.noop),
migrations.AlterField(
model_name="studentcard",
name="customer",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="student_card",
to="counter.customer",
verbose_name="student card",
),
),
migrations.AlterModelOptions(
name="studentcard",
options={
"verbose_name": "student card",
"verbose_name_plural": "student cards",
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.17 on 2024-12-15 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0026_alter_studentcard_customer"),
]
operations = [
migrations.AlterField(
model_name="refilling",
name="payment_method",
field=models.CharField(
choices=[("CHECK", "Check"), ("CASH", "Cash"), ("CARD", "Credit card")],
default="CARD",
max_length=255,
verbose_name="payment method",
),
),
]

View File

@ -0,0 +1,62 @@
# Generated by Django 4.2.17 on 2024-12-15 17:53
from django.db import migrations, models
from django.db.migrations.state import StateApps
def move_priority_to_order(apps: StateApps, schema_editor):
"""Migrate the previous homemade `priority` to `OrderedModel.order`.
`priority` was a system were click managers set themselves the priority
of a ProductType.
The higher the priority, the higher it was to be displayed in the eboutic.
Multiple product types could share the same priority, in which
case they were ordered by alphabetic order.
The new field is unique per object, and works in the other way :
the nearer from 0, the higher it should appear.
"""
ProductType = apps.get_model("counter", "ProductType")
product_types = list(ProductType.objects.order_by("-priority", "name"))
for order, product_type in enumerate(product_types):
product_type.order = order
ProductType.objects.bulk_update(product_types, ["order"])
class Migration(migrations.Migration):
dependencies = [("counter", "0027_alter_refilling_payment_method")]
operations = [
migrations.AlterField(
model_name="producttype",
name="comment",
field=models.TextField(
default="",
help_text="A text that will be shown on the eboutic.",
verbose_name="comment",
),
),
migrations.AlterField(
model_name="producttype",
name="description",
field=models.TextField(default="", verbose_name="description"),
),
migrations.AlterModelOptions(
name="producttype",
options={"ordering": ["order"], "verbose_name": "product type"},
),
migrations.AddField(
model_name="producttype",
name="order",
field=models.PositiveIntegerField(
db_index=True, default=0, editable=False, verbose_name="order"
),
preserve_default=False,
),
migrations.RunPython(
move_priority_to_order,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
migrations.RemoveField(model_name="producttype", name="priority"),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.17 on 2024-12-22 22:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0028_alter_producttype_comment_and_more"),
]
operations = [
migrations.AlterField(
model_name="selling",
name="label",
field=models.CharField(max_length=128, verbose_name="label"),
),
]

View File

@ -21,7 +21,7 @@ import string
from datetime import date, datetime, timedelta
from datetime import timezone as tz
from decimal import Decimal
from typing import Self, Tuple
from typing import Self
from dict2xml import dict2xml
from django.conf import settings
@ -35,6 +35,7 @@ from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from ordered_model.models import OrderedModel
from phonenumber_field.modelfields import PhoneNumberField
from accounting.models import CurrencyField
@ -42,7 +43,8 @@ from club.models import Club
from core.fields import ResizedImageField
from core.models import Group, Notification, User
from core.utils import get_start_of_semester
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
from counter.apps import PAYMENT_METHOD
from sith.settings import SITH_MAIN_CLUB
from subscription.models import Subscription
@ -136,7 +138,7 @@ class Customer(models.Model):
return (date.today() - subscription.subscription_end) < timedelta(days=90)
@classmethod
def get_or_create(cls, user: User) -> Tuple[Customer, bool]:
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
"""Work in pretty much the same way as the usual get_or_create method,
but with the default field replaced by some under the hood.
@ -288,32 +290,32 @@ class AccountDump(models.Model):
)
class ProductType(models.Model):
class ProductType(OrderedModel):
"""A product type.
Useful only for categorizing.
"""
name = models.CharField(_("name"), max_length=30)
description = models.TextField(_("description"), null=True, blank=True)
comment = models.TextField(_("comment"), null=True, blank=True)
description = models.TextField(_("description"), default="")
comment = models.TextField(
_("comment"),
default="",
help_text=_("A text that will be shown on the eboutic."),
)
icon = ResizedImageField(
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
)
# priority holds no real backend logic but helps to handle the order in which
# the items are to be shown to the user
priority = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = _("product type")
ordering = ["-priority", "name"]
ordering = ["order"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("counter:producttype_list")
return reverse("counter:product_type_list")
def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user."""
@ -325,8 +327,10 @@ class ProductType(models.Model):
class Product(models.Model):
"""A product, with all its related information."""
QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), blank=True)
description = models.TextField(_("description"), default="")
product_type = models.ForeignKey(
ProductType,
related_name="products",
@ -336,9 +340,15 @@ class Product(models.Model):
on_delete=models.SET_NULL,
)
code = models.CharField(_("code"), max_length=16, blank=True)
purchase_price = CurrencyField(_("purchase price"))
purchase_price = CurrencyField(
_("purchase price"),
help_text=_("Initial cost of purchasing the product"),
)
selling_price = CurrencyField(_("selling price"))
special_selling_price = CurrencyField(_("special selling price"))
special_selling_price = CurrencyField(
_("special selling price"),
help_text=_("Price for barmen during their permanence"),
)
icon = ResizedImageField(
height=70,
force_format="WEBP",
@ -352,14 +362,6 @@ class Product(models.Model):
)
limit_age = models.IntegerField(_("limit age"), default=0)
tray = models.BooleanField(_("tray price"), default=False)
parent_product = models.ForeignKey(
"self",
related_name="children_products",
verbose_name=_("parent product"),
null=True,
blank=True,
on_delete=models.SET_NULL,
)
buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True
)
@ -369,7 +371,7 @@ class Product(models.Model):
verbose_name = _("product")
def __str__(self):
return "%s (%s)" % (self.name, self.code)
return f"{self.name} ({self.code})"
def get_absolute_url(self):
return reverse("counter:product_list")
@ -525,14 +527,17 @@ class Counter(models.Model):
if user.is_anonymous:
return False
mem = self.club.get_membership_for(user)
if mem and mem.role >= 7:
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
def can_be_viewed_by(self, user: User) -> bool:
if self.type == "BAR":
return True
return user.is_board_member or user in self.sellers.all()
return (
self.type == "BAR"
or user.is_root
or user.is_in_group(pk=self.club.board_group_id)
or user in self.sellers.all()
)
def gen_token(self) -> None:
"""Generate a new token for this counter."""
@ -560,9 +565,6 @@ class Counter(models.Model):
"""Show if the counter authorize the refilling with physic money."""
if self.type != "BAR":
return False
if self.id in SITH_COUNTER_OFFICES:
# If the counter is either 'AE' or 'BdF', refills are authorized
return True
# at least one of the barmen is in the AE board
ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
return any(ae.get_membership_for(barman) for barman in self.barmen_list)
@ -652,6 +654,42 @@ class Counter(models.Model):
)
)["total"]
def customer_is_barman(self, customer: Customer | User) -> bool:
"""Check if this counter is a `bar` and if the customer is currently logged in.
This is useful to compute special prices."""
# Customer and User are two different tables,
# but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_products_for(self, customer: Customer) -> list[Product]:
"""
Get all allowed products for the provided customer on this counter
Prices will be annotated
"""
products = self.products.select_related("product_type").prefetch_related(
"buying_groups"
)
# Only include age appropriate products
age = customer.user.age
if customer.user.is_banned_alcohol:
age = min(age, 17)
products = products.filter(limit_age__lte=age)
# Compute special price for customer if he is a barmen on that bar
if self.customer_is_barman(customer):
products = products.annotate(price=F("special_selling_price"))
else:
products = products.annotate(price=F("selling_price"))
return [
product
for product in products.all()
if product.can_be_sold_to(customer.user)
]
class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self:
@ -690,8 +728,8 @@ class Refilling(models.Model):
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=settings.SITH_COUNTER_PAYMENT_METHOD,
default="CASH",
choices=PAYMENT_METHOD,
default="CARD",
)
bank = models.CharField(
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
@ -756,7 +794,8 @@ class SellingQuerySet(models.QuerySet):
class Selling(models.Model):
"""Handle the sellings."""
label = models.CharField(_("label"), max_length=64)
# We make sure that sellings have a way begger label than any product name is allowed to
label = models.CharField(_("label"), max_length=128)
product = models.ForeignKey(
Product,
related_name="sellings",
@ -1140,20 +1179,22 @@ class StudentCard(models.Model):
uid = models.CharField(
_("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)]
)
customer = models.ForeignKey(
customer = models.OneToOneField(
Customer,
related_name="student_cards",
verbose_name=_("student cards"),
null=False,
blank=False,
related_name="student_card",
verbose_name=_("student card"),
on_delete=models.CASCADE,
)
class Meta:
verbose_name = _("student card")
verbose_name_plural = _("student cards")
def __str__(self):
return self.uid
@staticmethod
def is_valid(uid):
def is_valid(uid: str) -> bool:
return (
(uid.isupper() or uid.isnumeric())
and len(uid) == StudentCard.UID_SIZE

View File

@ -1,33 +1,22 @@
from datetime import datetime
from typing import Annotated
from typing import Annotated, Self
from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema
from django.urls import reverse
from ninja import Field, FilterSchema, ModelSchema, Schema
from pydantic import model_validator
from counter.models import Counter, Permanency, Product
from club.schemas import ClubSchema
from core.schemas import GroupSchema, SimpleUserSchema
from counter.models import Counter, Product, ProductType
class CounterSchema(ModelSchema):
barmen_list: list[SimpleUserSchema]
is_open: bool
class Meta:
model = Counter
fields = ["id", "name", "type"]
class PermanencySchema(ModelSchema):
counter: CounterSchema
class Meta:
model = Permanency
fields = ["start", "end"]
class PermanencyFilterSchema(FilterSchema):
start_after: datetime | None = Field(None, q="start__gte")
start_before: datetime | None = Field(None, q="start__lte")
end_after: datetime | None = Field(None, q="end__gte")
end_before: datetime | None = Field(None, q="end__lte")
took_place_after: datetime | None = Field(None, q=["start__gte", "end__gte"])
counter: set[int] | None = Field(None, q="counter_id__in")
fields = ["id", "name", "type", "club", "products"]
class CounterFilterSchema(FilterSchema):
@ -40,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
fields = ["id", "name"]
class ProductSchema(ModelSchema):
class ProductTypeSchema(ModelSchema):
class Meta:
model = ProductType
fields = ["id", "name", "description", "comment", "icon", "order"]
url: str
@staticmethod
def resolve_url(obj: ProductType) -> str:
return reverse("counter:product_type_edit", kwargs={"type_id": obj.id})
class SimpleProductTypeSchema(ModelSchema):
class Meta:
model = ProductType
fields = ["id", "name"]
class ReorderProductTypeSchema(Schema):
below: int | None = None
above: int | None = None
@model_validator(mode="after")
def validate_exclusive(self) -> Self:
if self.below is None and self.above is None:
raise ValueError("Either 'below' or 'above' must be set.")
if self.below is not None and self.above is not None:
raise ValueError("Only one of 'below' or 'above' must be set.")
return self
class SimpleProductSchema(ModelSchema):
class Meta:
model = Product
fields = ["id", "name", "code"]
class ProductSchema(ModelSchema):
class Meta:
model = Product
fields = [
"id",
"name",
"code",
"description",
"purchase_price",
"selling_price",
"icon",
"limit_age",
"archived",
]
buying_groups: list[GroupSchema]
club: ClubSchema
product_type: SimpleProductTypeSchema | None
url: str
@staticmethod
def resolve_url(obj: Product) -> str:
return reverse("counter:product_edit", kwargs={"product_id": obj.id})
class ProductFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(
None, q=["name__icontains", "code__icontains"]
)
is_archived: bool | None = Field(None, q="archived")
buying_groups: set[int] | None = Field(None, q="buying_groups__in")
product_type: set[int] | None = Field(None, q="product_type__in")

View File

@ -0,0 +1,25 @@
import type { Product } from "#counter:counter/types";
export class BasketItem {
quantity: number;
product: Product;
quantityForTrayPrice: number;
errors: string[];
constructor(product: Product, quantity: number) {
this.quantity = quantity;
this.product = product;
this.errors = [];
}
getBonusQuantity(): number {
if (!this.product.hasTrayPrice) {
return 0;
}
return Math.floor(this.quantity / this.product.quantityForTrayPrice);
}
sum(): number {
return (this.quantity - this.getBonusQuantity()) * this.product.price;
}
}

View File

@ -4,9 +4,11 @@ import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type CounterSchema,
type ProductSchema,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
productSearchProducts,
producttypeFetchAll,
} from "#openapi";
@registerComponent("product-ajax-select")
@ -23,17 +25,48 @@ export class ProductAjaxSelect extends AjaxSelect {
return [];
}
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
}
}
@registerComponent("product-type-ajax-select")
export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[];
protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types
// and the filter logic is really simple.
// Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again.
if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null;
}
return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
);
}
protected renderOption(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("counter-ajax-select")
export class CounterAjaxSelect extends AjaxSelect {
protected valueField = "id";

View File

@ -0,0 +1,82 @@
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] {
const parsed = productParsingRegex.exec(query);
return [Number.parseInt(parsed[1] || "1"), parsed[2]];
}
@registerComponent("counter-product-select")
export class CounterProductSelect extends AutoCompleteSelectBase {
public getOperationCodes(): string[] {
return ["FIN", "ANN"];
}
public getSelectedProduct(): [number, string] {
return parseProduct(this.widget.getValue() as string);
}
protected attachBehaviors(): void {
this.allowMultipleProducts();
this.parseCodes();
}
private parseCodes(): void {
// We guess the code from the product name so we can prioritize search on it
// If no code is found, we just ignore it and everything still is fine
for (const option of Object.values(this.widget.options)) {
const match = codeParsingRegex.exec(option.text);
if (match !== null) {
option.code = match[1];
}
}
}
private allowMultipleProducts(): void {
const search = this.widget.search;
const onOptionSelect = this.widget.onOptionSelect;
this.widget.hook("instead", "search", (query: string) => {
return search.call(this.widget, parseProduct(query)[1]);
});
this.widget.hook(
"instead",
"onOptionSelect",
(evt: MouseEvent | KeyboardEvent, option: HTMLElement) => {
const [quantity, _] = parseProduct(this.widget.inputValue());
const originalValue = option.getAttribute("data-value") ?? option.innerText;
if (quantity === 1 || this.getOperationCodes().includes(originalValue)) {
return onOptionSelect.call(this.widget, evt, option);
}
const value = `${quantity}x${originalValue}`;
const label = `${quantity}x${option.innerText}`;
this.widget.addOption({ value: value, text: label }, true);
return onOptionSelect.call(
this.widget,
evt,
this.widget.getOption(value, true),
);
},
);
this.widget.hook("after", "onOptionSelect", () => {
/* Focus the next element if it's an input */
if (this.nextElementSibling.nodeName === "INPUT") {
(this.nextElementSibling as HTMLInputElement).focus();
}
});
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
/* We disable the dropdown on focus because we're going to always autofocus the widget */
return {
...super.tomSelectSettings(),
openOnFocus: false,
searchField: ["code", "text"],
};
}
}

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