225 Commits

Author SHA1 Message Date
5788c1a8b3 WIP 2025-06-12 15:04:38 +02:00
ca31584ce1 get_list_exact_or_404 util function 2025-06-12 15:04:38 +02:00
30a8a9dc7c fix some tests 2025-06-12 15:04:38 +02:00
2e2676fd1f Migrate albums and pictures to their own tables 2025-06-12 15:04:38 +02:00
9c8e3b7cac Merge pull request #1118 from ae-utbm/notifs
Notification improvements
2025-06-11 17:58:42 +02:00
c07f0c33cb fix permanent notification callback 2025-06-11 17:38:21 +02:00
7b778d3e6b Merge pull request #1114 from ae-utbm/accordions
Improve accordion animation
2025-06-11 14:00:46 +02:00
Sli
4c67bb1e2a Support animation with calc-size and detect browser features 2025-06-11 13:43:00 +02:00
Sli
96f91138dd Fix accordion transition on chrome 2025-06-11 00:20:46 +02:00
Sli
36d4a02a45 Remove js size animation and only use the opacity one 2025-06-10 15:06:59 +02:00
Sli
4774a7b741 Improve accordion animation 2025-06-05 20:38:32 +02:00
d58c713fc5 Merge pull request #1113 from ae-utbm/accordions
Fix bad css scoping on accordions
2025-06-05 20:37:23 +02:00
Sli
6f48a9a151 Fix bad css scoping on accordions 2025-06-05 19:57:25 +02:00
99be8a56f3 Merge pull request #1109 from ae-utbm/remove-laundry
Remove remaining laundry code
2025-06-05 18:28:16 +02:00
e04a99cabd Merge pull request #1111 from ae-utbm/calendar-cache
Disable calendar cache on API
2025-06-05 18:27:51 +02:00
Sli
bfea0989fb Disable cache on ics calendar on API response headers 2025-06-05 18:20:25 +02:00
be32486115 Merge pull request #1106 from ae-utbm/navbar-keyboard-navigation
Refactor navbar css and use details instead of div for better semantics
2025-06-05 18:17:58 +02:00
861447ae36 Merge pull request #1105 from ae-utbm/accordions
Remove jquery-ui accordions
2025-06-05 18:09:45 +02:00
Sli
5f701d1a17 Fix centering of detail elements 2025-06-05 18:04:45 +02:00
Sli
64fd123a85 Remove shadow and disable hovering on mobile view 2025-06-05 17:49:47 +02:00
Sli
7090254658 Refactor navbar css and use details instead of div for better semantics 2025-06-05 17:49:47 +02:00
d80f2e73e8 Merge pull request #1110 from ae-utbm/fix-old-promo
fix promo logo older than promo 10
2025-06-05 16:38:10 +02:00
ee3646594b fix promo logo older than promo 10 2025-06-05 16:31:36 +02:00
b0d9063153 remove remaining laundry code 2025-06-04 12:53:22 +02:00
Sli
0980fccf93 Add accordion animation with js 2025-06-03 20:48:45 +02:00
Sli
fb3fd9536e Improve accordion icon 2025-06-03 20:48:45 +02:00
Sli
3892e1cee2 Add fade animation 2025-06-03 20:48:45 +02:00
Sli
c10b488080 Remove jquery-ui accordions 2025-06-03 20:48:45 +02:00
ad91c8ed4f Merge pull request #1108 from ae-utbm/revert-python
Revert "bump python to 3.13"
2025-06-03 17:15:07 +02:00
3b90bd54fc Revert "bump python to 3.13"
This reverts commit f0fa27a8b5.
2025-06-03 10:37:04 +02:00
350a92bc44 Merge pull request #1102 from ae-utbm/update-deps
Update dependencies
2025-06-02 18:26:51 +02:00
95b4b0ba03 Merge pull request #1087 from ae-utbm/remove-laundry
remove laundry
2025-05-26 13:01:01 +02:00
bab44b31b1 Merge pull request #1100 from ae-utbm/filter-counter
Filter counters in ClubSellingView
2025-05-26 12:59:47 +02:00
f0fa27a8b5 bump python to 3.13 2025-05-26 12:56:35 +02:00
6d16e35624 update dependencies 2025-05-26 12:35:24 +02:00
13b892cd01 Merge pull request #1101 from ae-utbm/redirect-me
redirect `/user/me/*`
2025-05-26 10:30:07 +02:00
54be8addeb remove laundry 2025-05-26 09:10:35 +02:00
9256aff944 redirect /user/me/* 2025-05-26 08:48:18 +02:00
7c989cd749 filter selectable counters in ClubSellingView 2025-05-24 10:25:55 +02:00
2fc51e9901 Merge pull request #1099 from ae-utbm/hide-cash-register
hide cash register views to barmen
2025-05-22 11:20:52 +02:00
943fb5979d Merge pull request #1098 from ae-utbm/cached-groups
simplify `User.cached_groups`
2025-05-22 10:58:00 +02:00
ff1f1040b6 simplify User.cached_groups 2025-05-22 10:43:49 +02:00
fa90477de5 hide cash register views to barmen 2025-05-22 10:06:22 +02:00
af613c4cca Merge pull request #1094 from ae-utbm/tooltips
Create a js tooltip library
2025-05-17 07:01:15 +02:00
Sli
13f8b5db61 Fix bug where help button moves as if possessed 2025-05-14 15:04:20 +02:00
Sli
e63a09ee7e Synchronize tooltip-id and don't append tooltip to body twice 2025-05-14 15:02:48 +02:00
Sli
6bb6be011c Fix tooltip fading transitions and synchronize additional attributes 2025-05-14 15:02:46 +02:00
Sli
3a5bff8810 Fix tooltip not appearing/disapearing when attribute is removed 2025-05-14 15:01:19 +02:00
Sli
d1e5c93a08 Improve tooltips by using mutation observers 2025-05-14 15:01:19 +02:00
Sli
19aac8f302 Fix tooltip size 2025-05-14 15:01:19 +02:00
Sli
9bd3c618a4 Add doc, tooltip offset and css cleanup 2025-05-14 15:01:19 +02:00
Sli
68b1a96270 Allow popup customization 2025-05-14 15:01:18 +02:00
Sli
48bf72f623 Improve tooltips performance 2025-05-14 15:00:51 +02:00
Sli
1872e4abe5 Adapt calendar to new tooltip library 2025-05-14 15:00:51 +02:00
Sli
35e96fb875 Create basic tooltip library 2025-05-14 14:58:59 +02:00
5e953d04fe Merge pull request #1086 from ae-utbm/calendar_link_help
Calendar link help button
2025-05-14 13:14:22 +02:00
cab2adb45d fixed translations 2025-05-12 16:02:39 +02:00
2ed0fad51a remove unwanted focus behavior 2025-05-11 16:06:22 +02:00
39422a0cb8 added tooltip translations 2025-05-11 16:05:47 +02:00
33ba1e8bbb added button to layout 2025-05-11 16:05:21 +02:00
5ca1f03d9d ics help button 2025-05-11 15:23:11 +02:00
ac0c2c9880 Merge pull request #979 from ae-utbm/celery
Use celery for task queue
2025-05-07 11:53:39 +02:00
1a81911cd6 adapt celery to honcho setup 2025-05-07 11:49:38 +02:00
934d17d9d2 celery 2025-04-28 12:15:38 +02:00
f647feb8c8 use .env for project configuration 2025-04-28 12:15:38 +02:00
73e9c3132b Merge pull request #1093 from ae-utbm/calendar
Remove cache workaround on news calendar moderation
2025-04-25 12:23:57 +02:00
Sli
5da417c1a0 Remove cache workaround on news calendar moderation 2025-04-25 10:20:32 +02:00
df26ab4d50 Merge pull request #1085 from ae-utbm/eboutic
Don't use cookies for processing eboutic baskets
2025-04-23 15:56:44 +02:00
Sli
5dc9e24cd0 Apply review comments 2025-04-23 15:00:51 +02:00
Sli
7c9a966e54 Fix eboutic display bug 2025-04-23 14:04:08 +02:00
Sli
a31dc8254a Test pay with card 2025-04-23 00:53:39 +02:00
Sli
2a7c1a6438 Test pay with sith 2025-04-23 00:11:50 +02:00
Sli
8af6af1303 Test basket creation 2025-04-22 23:03:06 +02:00
4f5a69c353 Merge pull request #1091 from ae-utbm/docs-install
Update installation istructions
2025-04-21 13:09:24 +02:00
7296640a8d Merge pull request #1090 from ae-utbm/fix-album-upload
fix sas album creation rights
2025-04-21 01:01:36 +02:00
Sli
e556305062 Update dev install docs 2025-04-20 18:04:56 +02:00
e9c956e08c fix sas album creation rights 2025-04-19 17:05:15 +02:00
da56a7f651 Merge pull request #1089 from ae-utbm/login-register
Fix overflow issue on login/register page
2025-04-17 15:20:43 +02:00
Sli
0654dfb05d Fix overflow issue on login/register page 2025-04-17 13:02:40 +02:00
2a381101ac Merge pull request #1088 from ae-utbm/fix-anonymous-sas
fix 500 on SAS main page for anonymous users
2025-04-17 11:14:36 +02:00
ccd4275b02 fix 500 on SAS main page for anonymous users 2025-04-17 09:00:22 +02:00
Sli
f64409c612 Add more error messages on main view 2025-04-15 23:41:58 +02:00
Sli
f352b89fc0 Add auto basket invalidation 2025-04-15 23:33:57 +02:00
Sli
2ae9baa82f Fix etransaction_data endpoint 2025-04-15 18:50:25 +02:00
Sli
bc99390b25 Rename makecommand to checkout 2025-04-15 18:42:17 +02:00
Sli
262ed7eb4c Don't use cookies for processing eboutic baskets 2025-04-15 00:07:07 +02:00
4fa83d0667 Merge pull request #1083 from ae-utbm/promo-24-logo
new promo 24 logo
2025-04-14 12:17:40 +02:00
6b55b981ca new promo 24 logo 2025-04-14 11:02:41 +02:00
e6668728f2 Merge pull request #1082 from ae-utbm/eurok-consent
Add consent form for eurok partnership
2025-04-14 10:53:05 +02:00
Sli
3431fbf2d1 Add consent form for eurok partnership 2025-04-14 01:25:50 +02:00
77853b808a Merge pull request #1081 from ae-utbm/remove-accounting
remove remaining accounting code
2025-04-13 22:10:02 +02:00
05756520a3 remove remaining accounting code 2025-04-13 14:14:26 +02:00
b3eb6a945f Merge pull request #1076 from ae-utbm/eboutic-billing
Use htmx to fill up billing info
2025-04-13 09:52:49 +02:00
169faec479 Merge pull request #1080 from ae-utbm/fix-sas-order
Fix sas order
2025-04-13 01:06:31 +02:00
Sli
42317bfecc Use django messages for billing info ux 2025-04-13 00:20:57 +02:00
Sli
d6e858e0e3 Apply review comments 2025-04-12 22:59:25 +02:00
Sli
d8be9a62b5 Use assertRedirects for test_not_authorized 2025-04-12 22:46:23 +02:00
2658244671 fix album ordering on SAS 2025-04-12 18:09:39 +02:00
Sli
31575d0b64 Add tests 2025-04-11 17:12:23 +02:00
Sli
4ce885ac6b Default France value and cleaner handling of BillingInfo creation 2025-04-11 17:12:23 +02:00
Sli
5c2f324e13 Use htmx to fill up billing info 2025-04-11 17:12:22 +02:00
ed52a4f828 Merge pull request #1079 from ae-utbm/update-django
Update django
2025-04-11 17:11:58 +02:00
ff220e67c1 use new django.url.reverse query kwarg 2025-04-11 14:54:06 +02:00
805ffc498f response.content.decode() => response.text 2025-04-11 14:54:06 +02:00
ad4afce67f fix club members tests 2025-04-11 14:54:06 +02:00
f4276d6be5 fix account creation view tests 2025-04-11 14:54:06 +02:00
64085ac2a4 bump django to 5.2 2025-04-11 14:54:06 +02:00
f301365ebb update dependencies 2025-04-11 14:54:06 +02:00
53038a365f Merge pull request #1074 from ae-utbm/import-error
Fix cyclic import error on core/views/user.py
2025-04-10 11:57:42 +02:00
Sli
d2fe0f1fab Fix cyclic import error on core/views/user.py 2025-04-10 11:49:59 +02:00
e96d224a8d Merge pull request #1035 from ae-utbm/picture-upload
Picture upload from markdown editor
2025-04-10 11:41:54 +02:00
Sli
6128b6564c Ensure quickupload image field uniqueness 2025-04-10 11:38:33 +02:00
Sli
0f961c71e0 Auto delete image files when object has been deleted 2025-04-10 11:22:47 +02:00
59b275ef43 Merge pull request #1073 from ae-utbm/header-fix
Fix unaligned tool link
2025-04-10 11:17:24 +02:00
Sli
6362fcdf2d Fix unaligned tool link 2025-04-10 11:04:03 +02:00
Sli
3e61560875 Use group permissions 2025-04-10 01:01:40 +02:00
Sli
744223b76f Auto rescale quick upload image sizes 2025-04-09 22:50:51 +02:00
Sli
6e39b59dd5 Use UploadedImage to check image correctness and better error responses 2025-04-09 22:15:12 +02:00
Sli
67bc49fb21 Serve upload files directly from nginx 2025-04-09 20:55:24 +02:00
Sli
91b30e7550 Add quick upload tests 2025-04-09 20:55:24 +02:00
Sli
c236092c4f Create dedicated image upload model 2025-04-09 20:53:23 +02:00
Sli
7b23196071 Add image upload to easymde widget 2025-04-09 20:52:38 +02:00
Sli
10367d21ab Add API endpoint to upload images 2025-04-09 20:52:38 +02:00
60fd72917d Merge pull request #1053 from ae-utbm/sas-upload
Improve SAS upload
2025-04-09 19:32:55 +02:00
2c7b94547c improve upload error display 2025-04-09 12:33:40 +02:00
376af35bfb Check that uploaded images are actually images 2025-04-08 17:21:30 +02:00
13f417ba30 Use Alpine and the API for SAS picture upload 2025-04-08 15:59:30 +02:00
b83fbf91e1 extract album creation form into its own fragment 2025-04-08 15:59:30 +02:00
156305a16a add api endpoint to upload a sas picture 2025-04-08 15:59:30 +02:00
11efa4fca2 Merge pull request #1070 from ae-utbm/calendar-link
Remote calendar link for external sync
2025-04-08 15:50:38 +02:00
26456e3a7f Merge pull request #1071 from ae-utbm/news-overflow
Fix news x overflow on mobile
2025-04-08 15:35:17 +02:00
Sli
fab0d19eeb Fix news x overflow on mobile 2025-04-08 15:12:19 +02:00
Sli
8a381aed38 Smooth animation 2025-04-08 11:54:19 +02:00
Sli
5de05c0360 Introduce position attributes for tooltips 2025-04-08 10:27:08 +02:00
2e1a849aff modification du style du tooltip 2025-04-07 19:15:16 +02:00
Sli
b09d5e5ffd Remote calendar link for external sync 2025-04-07 13:58:08 +02:00
811c83552f Merge pull request #1051 from ae-utbm/fragment-mixin
Fragment mixins
2025-04-07 11:31:50 +02:00
b6511d5b84 Merge pull request #1069 from ae-utbm/upgrade-ical
Bump ical to 9.1.0 version
2025-04-06 23:34:55 +02:00
Sli
e52b2eadbe Bump ical to 9.1.0 version 2025-04-06 23:31:15 +02:00
86b8745665 Merge pull request #1068 from ae-utbm/dependabot/npm_and_yarn/vite-6.2.5
Bump vite from 6.2.3 to 6.2.5
2025-04-06 23:27:22 +02:00
597339749a Bump vite from 6.2.3 to 6.2.5
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.2.3 to 6.2.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-06 21:17:09 +00:00
d94d90357e Merge pull request #1067 from ae-utbm/taiste
bug fixing, external calendar removal and eurockéennes partnership
2025-04-06 23:16:04 +02:00
59e8272c7f Merge pull request #1064 from ae-utbm/makecommand
refactor eboutic command page
2025-04-06 22:36:56 +02:00
d98718f7ba fix makecommand jinja indentation 2025-04-06 22:29:43 +02:00
d03c425a17 refactor eboutic command page 2025-04-06 22:29:39 +02:00
e35c1d1928 move eboutic/makecommand.js to bundled directory 2025-04-06 22:29:26 +02:00
3b9c8d7b03 Merge pull request #1049 from ae-utbm/dependabot/npm_and_yarn/vite-6.2.3
Bump vite from 6.0.7 to 6.2.3
2025-04-06 21:36:52 +02:00
322cb74635 Merge pull request #1058 from ae-utbm/dependabot/npm_and_yarn/babel/runtime-7.27.0
Bump @babel/runtime from 7.26.0 to 7.27.0
2025-04-06 21:35:40 +02:00
62c394eec4 Merge pull request #1065 from ae-utbm/eurocks
Eurockéennes 2025
2025-04-06 21:29:01 +02:00
f254490790 Merge pull request #1063 from ae-utbm/fixes
Fixes
2025-04-06 21:28:45 +02:00
a78ccbd2cc Merge pull request #1066 from ae-utbm/remove-gcalendar
Remove external calendar
2025-04-06 17:21:33 +02:00
77537a84c2 remove external calendar 2025-04-06 17:14:39 +02:00
65c06dda8b partnership eurockéennes 2025 2025-04-06 17:01:00 +02:00
7623474124 add fragments documentation 2025-04-06 14:36:00 +02:00
6a5da0302d add FragmentMixin and UseFragmentsMixin classes 2025-04-06 14:34:22 +02:00
9e0cb7647b fix counter stats page access 2025-04-06 14:18:20 +02:00
fe5c685204 fix displayed user tabs 2025-04-06 14:17:12 +02:00
b0e24350e2 fix com admin pages 2025-04-06 14:17:12 +02:00
98e470fa2a Merge pull request #1060 from ae-utbm/master
Merge Back
2025-04-04 15:11:31 +02:00
49cca67eba Merge pull request #1062 from ae-utbm/fix-doc-ci
fix doc deployment (again)
2025-04-04 15:08:36 +02:00
a6e23b0b4c fix doc deployment (v2) 2025-04-04 15:05:08 +02:00
5c48924387 Merge pull request #1061 from ae-utbm/fix-doc-ci
fix doc deployment
2025-04-04 15:01:44 +02:00
e4264d400a fix doc deployment 2025-04-04 14:46:38 +02:00
b541e7c1fc Merge pull request #1059 from ae-utbm/fix-club-detail
Fix club detail
2025-04-04 14:37:56 +02:00
89efda6e26 fix club detail on ClubView 2025-04-04 14:27:07 +02:00
056b3a1702 split club/tests.py 2025-04-04 14:27:07 +02:00
df5838034e Bump @babel/runtime from 7.26.0 to 7.27.0
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.26.0 to 7.27.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-version: 7.27.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-04 10:09:43 +00:00
29c1142537 Merge pull request #1057 from ae-utbm/taiste
Honcho, more product filters, sas improvements, club refactor, accounting removal, dcons and more
2025-04-04 12:08:36 +02:00
3d40e92958 Merge pull request #1056 from ae-utbm/fixes
Fixes
2025-04-04 11:25:18 +02:00
b8a40027b8 remove data migration in migration 0030 of counter 2025-04-04 11:00:31 +02:00
c527e87fd1 fix club edition page 2025-04-04 11:00:31 +02:00
8699750c72 Merge pull request #1045 from ae-utbm/accounting
Remove accounting appp
2025-04-04 10:38:28 +02:00
24f6a2b1cc remove unused translations 2025-04-04 10:35:47 +02:00
cdd32c9a82 remove accounting models 2025-04-04 10:35:17 +02:00
a6ac10e60c clean populate.py 2025-04-04 10:35:17 +02:00
26d4c4b811 move accound refound view to counter 2025-04-04 10:35:17 +02:00
002554b802 move CurrencyField to counter 2025-04-04 10:35:17 +02:00
6dfd4e16e2 remove accounting views 2025-04-04 10:27:51 +02:00
635bc79dd6 Merge pull request #1041 from ae-utbm/dcons
Returnable products management
2025-04-04 09:36:54 +02:00
eee78008b1 add pages to manage returnable products 2025-04-04 09:33:02 +02:00
e7bb08448c feat: generic returnable products 2025-04-04 09:29:54 +02:00
7515e739b6 Merge pull request #1037 from ae-utbm/unix-name
Refactor Club
2025-04-04 09:27:33 +02:00
c7d02d4c77 Merge pull request #1055 from ae-utbm/april
April fool
2025-04-01 15:45:59 +02:00
9a691b5b0a Revert "April fool day"
This reverts commit 6e0e633660.
2025-04-01 12:01:20 +02:00
6e0e633660 April fool day
Définitivement une des blagues de tous les temps
2025-04-01 12:01:17 +02:00
b9a8b46049 Merge pull request #1052 from ae-utbm/remove-galaxy
Remove galaxy from production
2025-03-28 19:41:44 +01:00
812e0f5f4c remove galaxy from production 2025-03-28 18:34:16 +01:00
3d3c6adfa5 test club edit view 2025-03-28 17:42:56 +01:00
b14b498eb1 fix generate_galaxy_test_data 2025-03-28 17:42:56 +01:00
fb4909fc36 merge ClubEditView and ClubEditPropView 2025-03-28 17:42:56 +01:00
805b146f17 change Club.unix_name to Club.slug_name and remove it from forms 2025-03-28 17:42:53 +01:00
f764ce1585 remove ClubStatView 2025-03-28 17:40:52 +01:00
15d541b596 Merge pull request #1050 from ae-utbm/merge_same_users
Check that a user cannot be merged into itself
2025-03-28 15:01:26 +01:00
df2d0d4d4c methode clean dans MergeForm
fixed formatting

Update rootplace/forms.py

Co-authored-by: thomas girod <56346771+imperosol@users.noreply.github.com>

Check that a user cannot be merged into itself

ajout des traductions

changed test language to french

Check that a user cannot be merged into itself
2025-03-28 14:44:55 +01:00
ac1e40038e Merge pull request #1048 from ae-utbm/sas-visibility
Make SAS pictures visible for their owner
2025-03-27 12:11:13 +01:00
146c14fc86 Bump vite from 6.0.7 to 6.2.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.0.7 to 6.2.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-25 15:20:25 +00:00
e1eb634c62 Make SAS pictures visible for their owner 2025-03-24 15:38:00 +01:00
bb3dfb7e8a Merge pull request #1044 from ae-utbm/fix-deprecations
Fix some deprecations
2025-03-14 12:37:11 +01:00
93d11bb439 refactor RefoundAccountView permission checking 2025-03-13 18:50:19 +01:00
99e1318071 increase max pagination size from 199 to 200 2025-03-13 18:50:16 +01:00
d16237d015 make PageCreateView a PermissionRequiredMixin 2025-03-13 18:49:42 +01:00
8a38ebb09d skip useless checks when creating a subscription for a new member 2025-03-13 18:49:42 +01:00
7f2ee24cb9 deps: add BeautifulSoup as test dependency 2025-03-13 18:49:42 +01:00
9ac8728d30 fix deprecated ninja_extra context import 2025-03-13 18:49:42 +01:00
4b0cd04355 add missing ordering 2025-03-13 18:49:42 +01:00
5c5755d4a6 make logout a POST operation 2025-03-13 18:49:42 +01:00
aaa8c4ba67 Merge pull request #1031 from ae-utbm/ts-album
ajaxify album loading
2025-03-12 18:09:11 +01:00
04c7df8ac8 fix typescript types 2025-03-12 17:37:37 +01:00
0f6cda377c Fix paginated TS interfaces 2025-03-12 17:35:22 +01:00
60db7e2516 ajaxify album loading in the SAS 2025-03-12 17:35:22 +01:00
218aab1af3 api to fetch albums 2025-03-12 17:35:22 +01:00
650227b6e2 typescriptify album-index.js 2025-03-12 17:35:22 +01:00
598ff3ffdf Merge pull request #1043 from ae-utbm/upgrade
Update dependencies
2025-03-11 15:17:34 +01:00
76fc55b125 Merge pull request #1039 from ae-utbm/openapi
Compile openapi client in background when django runserver is reloading
2025-03-10 20:14:32 +01:00
7c3186da79 apply ruff rule A005 2025-03-10 10:33:05 +01:00
Sli
106dc32a3d Fix schema.json being auto deleted and remove formating and linting of generated openapi client 2025-03-09 16:30:21 +01:00
Sli
05edf33062 Compile openapi client in background when django runserver is reloading 2025-03-09 15:55:37 +01:00
bba5339407 apply ruff rule DJ012 2025-03-09 15:05:10 +01:00
d10393ea37 update dependencies 2025-03-09 15:04:53 +01:00
98175e397c Merge pull request #1040 from ae-utbm/country_flags
made country flags apply to windows in chrome browsers
2025-03-05 20:24:41 +01:00
62246f342d removed unnecessary event listener 2025-03-05 20:19:11 +01:00
bff6513192 renamed polyfill-index.ts to country-flags-index.ts 2025-03-05 20:13:20 +01:00
bf0779a096 fix formatting 2025-03-05 20:04:20 +01:00
9991507297 made country flags apply to windows in chrome browsers 2025-03-05 19:59:28 +01:00
2b99da5a37 Merge pull request #1034 from ae-utbm/taiste
Great news improvements, .env for configuration, full uv guide update command and more
2025-02-25 19:00:35 +01:00
247 changed files with 9588 additions and 10656 deletions

View File

@ -10,6 +10,7 @@ DATABASE_URL=sqlite:///db.sqlite3
REDIS_PORT=7963
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
TASK_BROKER_URL=redis://127.0.0.1:${REDIS_PORT}/1
# Used to select which other services to run alongside
# manage.py, pytest and runserver

View File

@ -11,6 +11,7 @@ env:
SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
TASK_BROKER_URL: redis://127.0.0.1:6379/1
jobs:
pre-commit:
@ -31,7 +32,7 @@ jobs:
strategy:
fail-fast: false # don't interrupt the other test processes
matrix:
pytest-mark: [slow, not slow]
pytest-mark: [not slow]
steps:
- name: Check out repository
uses: actions/checkout@v4

View File

@ -3,6 +3,10 @@ on:
push:
branches:
- master
env:
SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
permissions:
contents: write
jobs:

View File

@ -1,15 +1,15 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.8.3
rev: v0.11.11
hooks:
- id: ruff # just check the code, and print the errors
- id: ruff # actually fix the fixable errors, but print nothing
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
args: ["--fix", "--silent"]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/biomejs/pre-commit
rev: "v0.1.0" # Use the sha / tag you want to point at
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"]

View File

@ -1 +1,2 @@
redis: redis-server --port $REDIS_PORT
celery: uv run celery -A sith worker --beat -l INFO

View File

@ -1,14 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#

View File

@ -1,36 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib import admin
from accounting.models import (
AccountingType,
BankAccount,
ClubAccount,
Company,
GeneralJournal,
Label,
Operation,
SimplifiedAccountingType,
)
admin.site.register(BankAccount)
admin.site.register(ClubAccount)
admin.site.register(GeneralJournal)
admin.site.register(AccountingType)
admin.site.register(SimplifiedAccountingType)
admin.site.register(Operation)
admin.site.register(Label)
admin.site.register(Company)

View File

@ -1,23 +0,0 @@
from typing import Annotated
from annotated_types import MinLen
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.auth.api_permissions import CanAccessLookup
@api_controller("/lookup", permissions=[CanAccessLookup])
class AccountingController(ControllerBase):
@route.get("/club-account", response=PaginatedResponseSchema[ClubAccountSchema])
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club_account(self, search: Annotated[str, MinLen(1)]):
return ClubAccount.objects.filter(name__icontains=search).values()
@route.get("/company", response=PaginatedResponseSchema[CompanySchema])
@paginate(PageNumberPaginationExtra, page_size=50)
def search_company(self, search: Annotated[str, MinLen(1)]):
return Company.objects.filter(name__icontains=search).values()

View File

@ -1,280 +0,0 @@
from __future__ import unicode_literals
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
import accounting.models
class Migration(migrations.Migration):
dependencies = []
operations = [
migrations.CreateModel(
name="AccountingType",
fields=[
(
"id",
models.AutoField(
primary_key=True,
serialize=False,
verbose_name="ID",
auto_created=True,
),
),
(
"code",
models.CharField(
max_length=16,
verbose_name="code",
validators=[
django.core.validators.RegexValidator(
"^[0-9]*$",
"An accounting type code contains only numbers",
)
],
),
),
("label", models.CharField(max_length=128, verbose_name="label")),
(
"movement_type",
models.CharField(
choices=[
("CREDIT", "Credit"),
("DEBIT", "Debit"),
("NEUTRAL", "Neutral"),
],
max_length=12,
verbose_name="movement type",
),
),
],
options={
"verbose_name": "accounting type",
"ordering": ["movement_type", "code"],
},
),
migrations.CreateModel(
name="BankAccount",
fields=[
(
"id",
models.AutoField(
primary_key=True,
serialize=False,
verbose_name="ID",
auto_created=True,
),
),
("name", models.CharField(max_length=30, verbose_name="name")),
(
"iban",
models.CharField(max_length=255, blank=True, verbose_name="iban"),
),
(
"number",
models.CharField(
max_length=255, blank=True, verbose_name="account number"
),
),
],
options={"verbose_name": "Bank account", "ordering": ["club", "name"]},
),
migrations.CreateModel(
name="ClubAccount",
fields=[
(
"id",
models.AutoField(
primary_key=True,
serialize=False,
verbose_name="ID",
auto_created=True,
),
),
("name", models.CharField(max_length=30, verbose_name="name")),
],
options={
"verbose_name": "Club account",
"ordering": ["bank_account", "name"],
},
),
migrations.CreateModel(
name="Company",
fields=[
(
"id",
models.AutoField(
primary_key=True,
serialize=False,
verbose_name="ID",
auto_created=True,
),
),
("name", models.CharField(max_length=60, verbose_name="name")),
],
options={"verbose_name": "company"},
),
migrations.CreateModel(
name="GeneralJournal",
fields=[
(
"id",
models.AutoField(
primary_key=True,
serialize=False,
verbose_name="ID",
auto_created=True,
),
),
("start_date", models.DateField(verbose_name="start date")),
(
"end_date",
models.DateField(
null=True, verbose_name="end date", default=None, blank=True
),
),
("name", models.CharField(max_length=40, verbose_name="name")),
(
"closed",
models.BooleanField(verbose_name="is closed", default=False),
),
(
"amount",
accounting.models.CurrencyField(
decimal_places=2,
default=0,
verbose_name="amount",
max_digits=12,
),
),
(
"effective_amount",
accounting.models.CurrencyField(
decimal_places=2,
default=0,
verbose_name="effective_amount",
max_digits=12,
),
),
],
options={"verbose_name": "General journal", "ordering": ["-start_date"]},
),
migrations.CreateModel(
name="Operation",
fields=[
(
"id",
models.AutoField(
primary_key=True,
serialize=False,
verbose_name="ID",
auto_created=True,
),
),
("number", models.IntegerField(verbose_name="number")),
(
"amount",
accounting.models.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="amount"
),
),
("date", models.DateField(verbose_name="date")),
("remark", models.CharField(max_length=128, verbose_name="comment")),
(
"mode",
models.CharField(
choices=[
("CHECK", "Check"),
("CASH", "Cash"),
("TRANSFERT", "Transfert"),
("CARD", "Credit card"),
],
max_length=255,
verbose_name="payment method",
),
),
(
"cheque_number",
models.CharField(
max_length=32,
null=True,
verbose_name="cheque number",
default="",
blank=True,
),
),
("done", models.BooleanField(verbose_name="is done", default=False)),
(
"target_type",
models.CharField(
choices=[
("USER", "User"),
("CLUB", "Club"),
("ACCOUNT", "Account"),
("COMPANY", "Company"),
("OTHER", "Other"),
],
max_length=10,
verbose_name="target type",
),
),
(
"target_id",
models.IntegerField(
null=True, verbose_name="target id", blank=True
),
),
(
"target_label",
models.CharField(
max_length=32,
blank=True,
verbose_name="target label",
default="",
),
),
(
"accounting_type",
models.ForeignKey(
null=True,
related_name="operations",
verbose_name="accounting type",
to="accounting.AccountingType",
blank=True,
on_delete=django.db.models.deletion.CASCADE,
),
),
],
options={"ordering": ["-number"]},
),
migrations.CreateModel(
name="SimplifiedAccountingType",
fields=[
(
"id",
models.AutoField(
primary_key=True,
serialize=False,
verbose_name="ID",
auto_created=True,
),
),
("label", models.CharField(max_length=128, verbose_name="label")),
(
"accounting_type",
models.ForeignKey(
verbose_name="simplified accounting types",
to="accounting.AccountingType",
related_name="simplified_types",
on_delete=django.db.models.deletion.CASCADE,
),
),
],
options={
"verbose_name": "simplified type",
"ordering": ["accounting_type__movement_type", "accounting_type__code"],
},
),
]

View File

@ -1,105 +0,0 @@
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0001_initial"),
("accounting", "0001_initial"),
("core", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="operation",
name="invoice",
field=models.ForeignKey(
null=True,
related_name="operations",
verbose_name="invoice",
to="core.SithFile",
blank=True,
on_delete=django.db.models.deletion.CASCADE,
),
),
migrations.AddField(
model_name="operation",
name="journal",
field=models.ForeignKey(
verbose_name="journal",
to="accounting.GeneralJournal",
related_name="operations",
on_delete=django.db.models.deletion.CASCADE,
),
),
migrations.AddField(
model_name="operation",
name="linked_operation",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
blank=True,
to="accounting.Operation",
null=True,
related_name="operation_linked_to",
verbose_name="linked operation",
default=None,
),
),
migrations.AddField(
model_name="operation",
name="simpleaccounting_type",
field=models.ForeignKey(
null=True,
related_name="operations",
verbose_name="simple type",
to="accounting.SimplifiedAccountingType",
blank=True,
on_delete=django.db.models.deletion.CASCADE,
),
),
migrations.AddField(
model_name="generaljournal",
name="club_account",
field=models.ForeignKey(
verbose_name="club account",
to="accounting.ClubAccount",
related_name="journals",
on_delete=django.db.models.deletion.CASCADE,
),
),
migrations.AddField(
model_name="clubaccount",
name="bank_account",
field=models.ForeignKey(
verbose_name="bank account",
to="accounting.BankAccount",
related_name="club_accounts",
on_delete=django.db.models.deletion.CASCADE,
),
),
migrations.AddField(
model_name="clubaccount",
name="club",
field=models.ForeignKey(
verbose_name="club",
to="club.Club",
related_name="club_account",
on_delete=django.db.models.deletion.CASCADE,
),
),
migrations.AddField(
model_name="bankaccount",
name="club",
field=models.ForeignKey(
verbose_name="club",
to="club.Club",
related_name="bank_accounts",
on_delete=django.db.models.deletion.CASCADE,
),
),
migrations.AlterUniqueTogether(
name="operation", unique_together={("number", "journal")}
),
]

View File

@ -1,48 +0,0 @@
from __future__ import unicode_literals
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounting", "0002_auto_20160824_2152")]
operations = [
migrations.AddField(
model_name="company",
name="city",
field=models.CharField(blank=True, verbose_name="city", max_length=60),
),
migrations.AddField(
model_name="company",
name="country",
field=models.CharField(blank=True, verbose_name="country", max_length=32),
),
migrations.AddField(
model_name="company",
name="email",
field=models.EmailField(blank=True, verbose_name="email", max_length=254),
),
migrations.AddField(
model_name="company",
name="phone",
field=phonenumber_field.modelfields.PhoneNumberField(
blank=True, verbose_name="phone", max_length=128
),
),
migrations.AddField(
model_name="company",
name="postcode",
field=models.CharField(blank=True, verbose_name="postcode", max_length=10),
),
migrations.AddField(
model_name="company",
name="street",
field=models.CharField(blank=True, verbose_name="street", max_length=60),
),
migrations.AddField(
model_name="company",
name="website",
field=models.CharField(blank=True, verbose_name="website", max_length=64),
),
]

View File

@ -1,50 +0,0 @@
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounting", "0003_auto_20160824_2203")]
operations = [
migrations.CreateModel(
name="Label",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
primary_key=True,
auto_created=True,
serialize=False,
),
),
("name", models.CharField(max_length=64, verbose_name="label")),
(
"club_account",
models.ForeignKey(
related_name="labels",
verbose_name="club account",
to="accounting.ClubAccount",
on_delete=django.db.models.deletion.CASCADE,
),
),
],
),
migrations.AddField(
model_name="operation",
name="label",
field=models.ForeignKey(
on_delete=django.db.models.deletion.SET_NULL,
related_name="operations",
null=True,
blank=True,
verbose_name="label",
to="accounting.Label",
),
),
migrations.AlterUniqueTogether(
name="label", unique_together={("name", "club_account")}
),
]

View File

@ -1,17 +0,0 @@
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounting", "0004_auto_20161005_1505")]
operations = [
migrations.AlterField(
model_name="operation",
name="remark",
field=models.CharField(
null=True, max_length=128, blank=True, verbose_name="comment"
),
)
]

View File

@ -1,520 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from decimal import Decimal
from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
from django.db import models
from django.template import defaultfilters
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from club.models import Club
from core.models import SithFile, User
class CurrencyField(models.DecimalField):
"""Custom database field used for currency."""
def __init__(self, *args, **kwargs):
kwargs["max_digits"] = 12
kwargs["decimal_places"] = 2
super().__init__(*args, **kwargs)
def to_python(self, value):
try:
return super().to_python(value).quantize(Decimal("0.01"))
except AttributeError:
return None
if settings.TESTING:
from model_bakery import baker
baker.generators.add(
CurrencyField,
lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2),
)
else: # pragma: no cover
# baker is only used in tests, so we don't need coverage for this part
pass
# Accounting classes
class Company(models.Model):
name = models.CharField(_("name"), max_length=60)
street = models.CharField(_("street"), max_length=60, blank=True)
city = models.CharField(_("city"), max_length=60, blank=True)
postcode = models.CharField(_("postcode"), max_length=10, blank=True)
country = models.CharField(_("country"), max_length=32, blank=True)
phone = PhoneNumberField(_("phone"), blank=True)
email = models.EmailField(_("email"), blank=True)
website = models.CharField(_("website"), max_length=64, blank=True)
class Meta:
verbose_name = _("company")
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:co_edit", kwargs={"co_id": self.id})
def get_display_name(self):
return self.name
def is_owned_by(self, user):
"""Check if that object can be edited by the given user."""
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
return user.memberships.filter(
end_date=None, club__role=settings.SITH_CLUB_ROLES_ID["Treasurer"]
).exists()
def can_be_viewed_by(self, user):
"""Check if that object can be viewed by the given user."""
return user.memberships.filter(
end_date=None, club__role_gte=settings.SITH_CLUB_ROLES_ID["Treasurer"]
).exists()
class BankAccount(models.Model):
name = models.CharField(_("name"), max_length=30)
iban = models.CharField(_("iban"), max_length=255, blank=True)
number = models.CharField(_("account number"), max_length=255, blank=True)
club = models.ForeignKey(
Club,
related_name="bank_accounts",
verbose_name=_("club"),
on_delete=models.CASCADE,
)
class Meta:
verbose_name = _("Bank account")
ordering = ["club", "name"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:bank_details", kwargs={"b_account_id": self.id})
def is_owned_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
m = self.club.get_membership_for(user)
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
class ClubAccount(models.Model):
name = models.CharField(_("name"), max_length=30)
club = models.ForeignKey(
Club,
related_name="club_account",
verbose_name=_("club"),
on_delete=models.CASCADE,
)
bank_account = models.ForeignKey(
BankAccount,
related_name="club_accounts",
verbose_name=_("bank account"),
on_delete=models.CASCADE,
)
class Meta:
verbose_name = _("Club account")
ordering = ["bank_account", "name"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:club_details", kwargs={"c_account_id": self.id})
def is_owned_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
m = self.club.get_membership_for(user)
return m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
def can_be_viewed_by(self, user):
"""Check if that object can be viewed by the given user."""
m = self.club.get_membership_for(user)
return m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
def has_open_journal(self):
return self.journals.filter(closed=False).exists()
def get_open_journal(self):
return self.journals.filter(closed=False).first()
def get_display_name(self):
return _("%(club_account)s on %(bank_account)s") % {
"club_account": self.name,
"bank_account": self.bank_account,
}
class GeneralJournal(models.Model):
"""Class storing all the operations for a period of time."""
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"), null=True, blank=True, default=None)
name = models.CharField(_("name"), max_length=40)
closed = models.BooleanField(_("is closed"), default=False)
club_account = models.ForeignKey(
ClubAccount,
related_name="journals",
null=False,
verbose_name=_("club account"),
on_delete=models.CASCADE,
)
amount = CurrencyField(_("amount"), default=0)
effective_amount = CurrencyField(_("effective_amount"), default=0)
class Meta:
verbose_name = _("General journal")
ordering = ["-start_date"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:journal_details", kwargs={"j_id": self.id})
def is_owned_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
return self.club_account.can_be_edited_by(user)
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
return self.club_account.can_be_edited_by(user)
def can_be_viewed_by(self, user):
return self.club_account.can_be_viewed_by(user)
def update_amounts(self):
self.amount = 0
self.effective_amount = 0
for o in self.operations.all():
if o.accounting_type.movement_type == "CREDIT":
if o.done:
self.effective_amount += o.amount
self.amount += o.amount
else:
if o.done:
self.effective_amount -= o.amount
self.amount -= o.amount
self.save()
class Operation(models.Model):
"""An operation is a line in the journal, a debit or a credit."""
number = models.IntegerField(_("number"))
journal = models.ForeignKey(
GeneralJournal,
related_name="operations",
null=False,
verbose_name=_("journal"),
on_delete=models.CASCADE,
)
amount = CurrencyField(_("amount"))
date = models.DateField(_("date"))
remark = models.CharField(_("comment"), max_length=128, null=True, blank=True)
mode = models.CharField(
_("payment method"),
max_length=255,
choices=settings.SITH_ACCOUNTING_PAYMENT_METHOD,
)
cheque_number = models.CharField(
_("cheque number"), max_length=32, default="", null=True, blank=True
)
invoice = models.ForeignKey(
SithFile,
related_name="operations",
verbose_name=_("invoice"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
done = models.BooleanField(_("is done"), default=False)
simpleaccounting_type = models.ForeignKey(
"SimplifiedAccountingType",
related_name="operations",
verbose_name=_("simple type"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
accounting_type = models.ForeignKey(
"AccountingType",
related_name="operations",
verbose_name=_("accounting type"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
label = models.ForeignKey(
"Label",
related_name="operations",
verbose_name=_("label"),
null=True,
blank=True,
on_delete=models.SET_NULL,
)
target_type = models.CharField(
_("target type"),
max_length=10,
choices=[
("USER", _("User")),
("CLUB", _("Club")),
("ACCOUNT", _("Account")),
("COMPANY", _("Company")),
("OTHER", _("Other")),
],
)
target_id = models.IntegerField(_("target id"), null=True, blank=True)
target_label = models.CharField(
_("target label"), max_length=32, default="", blank=True
)
linked_operation = models.OneToOneField(
"self",
related_name="operation_linked_to",
verbose_name=_("linked operation"),
null=True,
blank=True,
default=None,
on_delete=models.CASCADE,
)
class Meta:
unique_together = ("number", "journal")
ordering = ["-number"]
def __str__(self):
return f"{self.amount} € | {self.date} | {self.accounting_type} | {self.done}"
def save(self, *args, **kwargs):
if self.number is None:
self.number = self.journal.operations.count() + 1
super().save(*args, **kwargs)
self.journal.update_amounts()
def get_absolute_url(self):
return reverse("accounting:journal_details", kwargs={"j_id": self.journal.id})
def __getattribute__(self, attr):
if attr == "target":
return self.get_target()
else:
return object.__getattribute__(self, attr)
def clean(self):
super().clean()
if self.date is None:
raise ValidationError(_("The date must be set."))
elif self.date < self.journal.start_date:
raise ValidationError(
_(
"""The date can not be before the start date of the journal, which is
%(start_date)s."""
)
% {
"start_date": defaultfilters.date(
self.journal.start_date, settings.DATE_FORMAT
)
}
)
if self.target_type != "OTHER" and self.get_target() is None:
raise ValidationError(_("Target does not exists"))
if self.target_type == "OTHER" and self.target_label == "":
raise ValidationError(
_("Please add a target label if you set no existing target")
)
if not self.accounting_type and not self.simpleaccounting_type:
raise ValidationError(
_(
"You need to provide ether a simplified accounting type or a standard accounting type"
)
)
if self.simpleaccounting_type:
self.accounting_type = self.simpleaccounting_type.accounting_type
@property
def target(self):
return self.get_target()
def get_target(self):
tar = None
if self.target_type == "USER":
tar = User.objects.filter(id=self.target_id).first()
elif self.target_type == "CLUB":
tar = Club.objects.filter(id=self.target_id).first()
elif self.target_type == "ACCOUNT":
tar = ClubAccount.objects.filter(id=self.target_id).first()
elif self.target_type == "COMPANY":
tar = Company.objects.filter(id=self.target_id).first()
return tar
def is_owned_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
if self.journal.closed:
return False
m = self.journal.club_account.club.get_membership_for(user)
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
if self.journal.closed:
return False
m = self.journal.club_account.club.get_membership_for(user)
return m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
class AccountingType(models.Model):
"""Accounting types.
Those are numbers used in accounting to classify operations
"""
code = models.CharField(
_("code"),
max_length=16,
validators=[
validators.RegexValidator(
r"^[0-9]*$", _("An accounting type code contains only numbers")
)
],
)
label = models.CharField(_("label"), max_length=128)
movement_type = models.CharField(
_("movement type"),
choices=[
("CREDIT", _("Credit")),
("DEBIT", _("Debit")),
("NEUTRAL", _("Neutral")),
],
max_length=12,
)
class Meta:
verbose_name = _("accounting type")
ordering = ["movement_type", "code"]
def __str__(self):
return self.code + " - " + self.get_movement_type_display() + " - " + self.label
def get_absolute_url(self):
return reverse("accounting:type_list")
def is_owned_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
class SimplifiedAccountingType(models.Model):
"""Simplified version of `AccountingType`."""
label = models.CharField(_("label"), max_length=128)
accounting_type = models.ForeignKey(
AccountingType,
related_name="simplified_types",
verbose_name=_("simplified accounting types"),
on_delete=models.CASCADE,
)
class Meta:
verbose_name = _("simplified type")
ordering = ["accounting_type__movement_type", "accounting_type__code"]
def __str__(self):
return (
f"{self.get_movement_type_display()} "
f"- {self.accounting_type.code} - {self.label}"
)
def get_absolute_url(self):
return reverse("accounting:simple_type_list")
@property
def movement_type(self):
return self.accounting_type.movement_type
def get_movement_type_display(self):
return self.accounting_type.get_movement_type_display()
class Label(models.Model):
"""Label allow a club to sort its operations."""
name = models.CharField(_("label"), max_length=64)
club_account = models.ForeignKey(
ClubAccount,
related_name="labels",
verbose_name=_("club account"),
on_delete=models.CASCADE,
)
class Meta:
unique_together = ("name", "club_account")
def __str__(self):
return "%s (%s)" % (self.name, self.club_account.name)
def get_absolute_url(self):
return reverse(
"accounting:label_list", kwargs={"clubaccount_id": self.club_account.id}
)
def is_owned_by(self, user):
if user.is_anonymous:
return False
return self.club_account.is_owned_by(user)
def can_be_edited_by(self, user):
return self.club_account.can_be_edited_by(user)
def can_be_viewed_by(self, user):
return self.club_account.can_be_viewed_by(user)

View File

@ -1,15 +0,0 @@
from ninja import ModelSchema
from accounting.models import ClubAccount, Company
class ClubAccountSchema(ModelSchema):
class Meta:
model = ClubAccount
fields = ["id", "name"]
class CompanySchema(ModelSchema):
class Meta:
model = Company
fields = ["id", "name"]

View File

@ -1,60 +0,0 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type ClubAccountSchema,
type CompanySchema,
accountingSearchClubAccount,
accountingSearchCompany,
} from "#openapi";
@registerComponent("club-account-ajax-select")
export class ClubAccountAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await accountingSearchClubAccount({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("company-ajax-select")
export class CompanyAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await accountingSearchCompany({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: CompanySchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: CompanySchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

View File

@ -1,27 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Accounting type list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
{% trans %}Accounting types{% endtrans %}
</p>
<hr>
<p><a href="{{ url('accounting:type_new') }}">{% trans %}New accounting type{% endtrans %}</a></p>
{% if accountingtype_list %}
<h3>{% trans %}Accounting type list{% endtrans %}</h3>
<ul>
{% for a in accountingtype_list %}
<li><a href="{{ url('accounting:type_edit', type_id=a.id) }}">{{ a }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no types in this website.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,38 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Bank account: {% endtrans %}{{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
{{ object.name }}
</p>
<hr>
<h2>{% trans %}Bank account: {% endtrans %}{{ object.name }}</h2>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and not object.club_accounts.exists() %}
<a href="{{ url('accounting:bank_delete', b_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
<h4>{% trans %}Infos{% endtrans %}</h4>
<ul>
<li><strong>{% trans %}IBAN: {% endtrans %}</strong>{{ object.iban }}</li>
<li><strong>{% trans %}Number: {% endtrans %}</strong>{{ object.number }}</li>
</ul>
<p><a href="{{ url('accounting:club_new') }}?parent={{ object.id }}">{% trans %}New club account{% endtrans %}</a></p>
<ul>
{% for c in object.club_accounts.all() %}
<li><a href="{{ url('accounting:club_details', c_account_id=c.id) }}">{{ c }}</a>
- <a href="{{ url('accounting:club_edit', c_account_id=c.id) }}">{% trans %}Edit{% endtrans %}</a>
{% if c.journals.count() == 0 %}
- <a href="{{ url('accounting:club_delete', c_account_id=c.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@ -1,33 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Bank account list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<h4>
{% trans %}Accounting{% endtrans %}
</h4>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<p><a href="{{ url('accounting:simple_type_list') }}">{% trans %}Manage simplified types{% endtrans %}</a></p>
<p><a href="{{ url('accounting:type_list') }}">{% trans %}Manage accounting types{% endtrans %}</a></p>
<p><a href="{{ url('accounting:bank_new') }}">{% trans %}New bank account{% endtrans %}</a></p>
{% endif %}
{% if bankaccount_list %}
<h3>{% trans %}Bank account list{% endtrans %}</h3>
<ul>
{% for a in object_list %}
<li><a href="{{ url('accounting:bank_details', b_account_id=a.id) }}">{{ a }}</a>
- <a href="{{ url('accounting:bank_edit', b_account_id=a.id) }}">{% trans %}Edit{% endtrans %}</a>
</li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no accounts in this website.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,68 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Club account:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
{{ object }}
</p>
<hr>
<h2>{% trans %}Club account:{% endtrans %} {{ object.name }}</h2>
{% if user.is_root and not object.journals.exists() %}
<a href="{{ url('accounting:club_delete', c_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
{% endif %}
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.id) }}">{% trans %}Label list{% endtrans %}</a></p>
{% if not object.has_open_journal() %}
<p><a href="{{ url('accounting:journal_new') }}?parent={{ object.id }}">{% trans %}New journal{% endtrans %}</a></p>
{% else %}
<p>{% trans %}You can not create new journal while you still have one opened{% endtrans %}</p>
{% endif %}
<table>
<thead>
<tr>
<td>{% trans %}Name{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Amount{% endtrans %}</td>
<td>{% trans %}Effective amount{% endtrans %}</td>
<td>{% trans %}Closed{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for j in object.journals.all() %}
<tr>
<td>{{ j.name }}</td>
<td>{{ j.start_date }}</td>
{% if j.end_date %}
<td>{{ j.end_date }}</td>
{% else %}
<td> - </td>
{% endif %}
<td>{{ j.amount }} €</td>
<td>{{ j.effective_amount }} €</td>
{% if j.closed %}
<td>{% trans %}Yes{% endtrans %}</td>
{% else %}
<td>{% trans %}No{% endtrans %}</td>
{% endif %}
<td> <a href="{{ url('accounting:journal_details', j_id=j.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('accounting:journal_edit', j_id=j.id) }}">{% trans %}Edit{% endtrans %}</a>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and j.operations.count() == 0 %}
<a href="{{ url('accounting:journal_delete', j_id=j.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,30 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Company list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
{% if user.is_root
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
%}
<p><a href="{{ url('accounting:co_new') }}">{% trans %}Create new company{% endtrans %}</a></p>
{% endif %}
<br/>
<table>
<thead>
<tr>
<td>{% trans %}Companies{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for o in object_list %}
<tr>
<td><a href="{{ url('accounting:co_edit', co_id=o.id) }}">{{ o.get_display_name() }}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,103 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
{{ object.name }}
</p>
<hr>
<h2>{% trans %}General journal:{% endtrans %} {{ object.name }}</h2>
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.club_account.id }}">{% trans %}New label{% endtrans %}</a></p>
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.club_account.id) }}">{% trans %}Label list{% endtrans %}</a></p>
<p><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></p>
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ object.amount }} € -
<strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ object.effective_amount }} €</p>
{% if object.closed %}
<p>{% trans %}Journal is closed, you can not create operation{% endtrans %}</p>
{% else %}
<p><a href="{{ url('accounting:op_new', j_id=object.id) }}">{% trans %}New operation{% endtrans %}</a></p>
</br>
{% endif %}
<div class="journal-table">
<table>
<thead>
<tr>
<td>{% trans %}Nb{% endtrans %}</td>
<td>{% trans %}Date{% endtrans %}</td>
<td>{% trans %}Label{% endtrans %}</td>
<td>{% trans %}Amount{% endtrans %}</td>
<td>{% trans %}Payment mode{% endtrans %}</td>
<td>{% trans %}Target{% endtrans %}</td>
<td>{% trans %}Code{% endtrans %}</td>
<td>{% trans %}Nature{% endtrans %}</td>
<td>{% trans %}Done{% endtrans %}</td>
<td>{% trans %}Comment{% endtrans %}</td>
<td>{% trans %}File{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
<td>{% trans %}PDF{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for o in object.operations.all() %}
<tr>
<td>{{ o.number }}</td>
<td>{{ o.date }}</td>
<td>{{ o.label or "" }}</td>
{% if o.accounting_type.movement_type == "DEBIT" %}
<td class="neg-amount">&nbsp;{{ o.amount }}&nbsp;€</td>
{% else %}
<td class="pos-amount">&nbsp;{{ o.amount }}&nbsp;€</td>
{% endif %}
<td>{{ o.get_mode_display() }}</td>
{% if o.target_type == "OTHER" %}
<td>{{ o.target_label }}</td>
{% else %}
<td><a href="{{ o.target.get_absolute_url() }}">{{ o.target.get_display_name() }}</a></td>
{% endif %}
<td>{{ o.accounting_type.code }}</td>
<td>{{ o.accounting_type.label }}</td>
{% if o.done %}
<td>{% trans %}Yes{% endtrans %}</td>
{% else %}
<td>{% trans %}No{% endtrans %}</td>
{% endif %}
<td>{{ o.remark }}
{% if not o.linked_operation and o.target_type == "ACCOUNT" and not o.target.has_open_journal() %}
<p><strong>
{% trans %}Warning: this operation has no linked operation because the targeted club account has no opened journal.{% endtrans %}
</strong></p>
<p><strong>
{% trans url=o.target.get_absolute_url() %}Open a journal in <a href="{{ url }}">this club account</a>, then save this operation again to make the linked operation.{% endtrans %}
</strong></p>
{% endif %}
</td>
{% if o.invoice %}
<td><a href="{{ url('core:download', file_id=o.invoice.id) }}">{{ o.invoice.name }}</a></td>
{% else %}
<td>-</td>
{% endif %}
<td>
{%
if o.journal.club_account.bank_account.name not in ["AE TI", "TI"]
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
%}
{% if not o.journal.closed %}
<a href="{{ url('accounting:op_edit', op_id=o.id) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% endif %}
</td>
<td><a href="{{ url('accounting:op_pdf', op_id=o.id) }}">{% trans %}Generate{% endtrans %}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -1,33 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<h3>{% trans %}Accounting statement: {% endtrans %} {{ object.name }}</h3>
<table>
<thead>
<tr>
<td>{% trans %}Operation type{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for k,v in statement.items() %}
<tr>
<td>{{ k }}</td>
<td>{{ "%.2f" % v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ "%.2f" % object.amount }} €</p>
<p><strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ "%.2f" %object.effective_amount }} €</p>
</div>
{% endblock %}

View File

@ -1,57 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% macro display_tables(dict) %}
<div id="accounting">
<h6>{% trans %}Credit{% endtrans %}</h6>
<table>
<thead>
<tr>
<td>{% trans %}Nature of operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for k,v in dict['CREDIT'].items() %}
<tr>
<td>{{ k }}</td>
<td>{{ "%.2f" % v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['CREDIT_sum'] }}
<h6>{% trans %}Debit{% endtrans %}</h6>
<table>
<thead>
<tr>
<td>{% trans %}Nature of operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for k,v in dict['DEBIT'].items() %}
<tr>
<td>{{ k }}</td>
<td>{{ "%.2f" % v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['DEBIT_sum'] }}
{% endmacro %}
{% block content %}
<h3>{% trans %}Statement by nature: {% endtrans %} {{ object.name }}</h3>
{% for k,v in statement.items() %}
<h4 style="background: lightblue; padding: 4px;">{{ k }} : {{ "%.2f" % (v['CREDIT_sum'] - v['DEBIT_sum']) }}</h4>
{{ display_tables(v) }}
<hr>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,68 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}General journal:{% endtrans %} {{ object.name }}
{% endblock %}
{% block content %}
<div id="accounting">
<h3>{% trans %}Statement by person: {% endtrans %} {{ object.name }}</h3>
<h4>{% trans %}Credit{% endtrans %}</h4>
<table>
<thead>
<tr>
<td>{% trans %}Target of the operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for key in credit_statement.keys() %}
<tr>
{% if key.target_type == "OTHER" %}
<td>{{ o.target_label }}</td>
{% elif key %}
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
{% else %}
<td></td>
{% endif %}
<td>{{ "%.2f" % credit_statement[key] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Total : {{ "%.2f" % total_credit }}</p>
<h4>{% trans %}Debit{% endtrans %}</h4>
<table>
<thead>
<tr>
<td>{% trans %}Target of the operation{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for key in debit_statement.keys() %}
<tr>
{% if key.target_type == "OTHER" %}
<td>{{ o.target_label }}</td>
{% elif key %}
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
{% else %}
<td></td>
{% endif %}
<td>{{ "%.2f" % debit_statement[key] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Total : {{ "%.2f" % total_debit }}</p>
</div>
{% endblock %}

View File

@ -1,36 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Label list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{{ object }}</a>
</p>
<hr>
<p><a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{% trans %}Back to club account{% endtrans %}</a></p>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
{% endif %}
{% if object.labels.all() %}
<h3>{% trans %}Label list{% endtrans %}</h3>
<ul>
{% for l in object.labels.all() %}
<li><a href="{{ url('accounting:label_edit', label_id=l.id) }}">{{ l }}</a>
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
-
<a href="{{ url('accounting:label_delete', label_id=l.id) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no label in this club account.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,123 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Edit operation{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
<a href="{{ url('accounting:journal_details', j_id=object.id) }}">{{ object.name }}</a> >
{% trans %}Edit operation{% endtrans %}
</p>
<hr>
<h2>{% trans %}Edit operation{% endtrans %}</h2>
<form action="" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.journal }}
{{ form.target_id }}
<p>{{ form.amount.errors }}<label for="{{ form.amount.name }}">{{ form.amount.label }}</label> {{ form.amount }}</p>
<p>{{ form.remark.errors }}<label for="{{ form.remark.name }}">{{ form.remark.label }}</label> {{ form.remark }}</p>
<br />
<strong>{% trans %}Warning: if you select <em>Account</em>, the opposite operation will be created in the target account. If you don't want that, select <em>Club</em> instead of <em>Account</em>.{% endtrans %}</strong>
<p>{{ form.target_type.errors }}<label for="{{ form.target_type.name }}">{{ form.target_type.label }}</label> {{ form.target_type }}</p>
{{ form.user }}
{{ form.club }}
{{ form.club_account }}
{{ form.company }}
{{ form.target_label }}
<span id="id_need_link_full"><label>{{ form.need_link.label }}</label> {{ form.need_link }}</span>
<p>{{ form.date.errors }}<label for="{{ form.date.name }}">{{ form.date.label }}</label> {{ form.date }}</p>
<p>{{ form.mode.errors }}<label for="{{ form.mode.name }}">{{ form.mode.label }}</label> {{ form.mode }}</p>
<p>{{ form.cheque_number.errors }}<label for="{{ form.cheque_number.name }}">{{ form.cheque_number.label }}</label> {{
form.cheque_number }}</p>
<p>{{ form.invoice.errors }}<label for="{{ form.invoice.name }}">{{ form.invoice.label }}</label> {{ form.invoice }}</p>
<p>{{ form.simpleaccounting_type.errors }}<label for="{{ form.simpleaccounting_type.name }}">{{
form.simpleaccounting_type.label }}</label> {{ form.simpleaccounting_type }}</p>
<p>{{ form.accounting_type.errors }}<label for="{{ form.accounting_type.name }}">{{ form.accounting_type.label }}</label> {{
form.accounting_type }}</p>
<p>{{ form.label.errors }}<label for="{{ form.label.name }}">{{ form.label.label }}</label> {{ form.label }}</p>
<p>{{ form.done.errors }}<label for="{{ form.done.name }}">{{ form.done.label }}</label> {{ form.done }}</p>
{% if form.instance.linked_operation %}
{% set obj = form.instance.linked_operation %}
<p><strong>{% trans %}Linked operation:{% endtrans %}</strong><br>
<a href="{{ url('accounting:bank_details', b_account_id=obj.journal.club_account.bank_account.id) }}">
{{obj.journal.club_account.bank_account }}</a> >
<a href="{{ url('accounting:club_details', c_account_id=obj.journal.club_account.id) }}">{{ obj.journal.club_account }}</a> >
<a href="{{ url('accounting:journal_details', j_id=obj.journal.id) }}">{{ obj.journal }}</a> >
{{ obj.number }}
</p>
{% endif %}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script>
$( function() {
var target_type = $('#id_target_type');
var user = $('user-ajax-select');
var club = $('club-ajax-select');
var club_account = $('club-account-ajax-select');
var company = $('company-ajax-select');
var other = $('#id_target_label');
var need_link = $('#id_need_link_full');
function update_targets () {
if (target_type.val() == "USER") {
console.log(user);
user.show();
club.hide();
club_account.hide();
company.hide();
other.hide();
need_link.hide();
} else if (target_type.val() == "ACCOUNT") {
club_account.show();
need_link.show();
user.hide();
club.hide();
company.hide();
other.hide();
} else if (target_type.val() == "CLUB") {
club.show();
user.hide();
club_account.hide();
company.hide();
other.hide();
need_link.hide();
} else if (target_type.val() == "COMPANY") {
company.show();
user.hide();
club_account.hide();
club.hide();
other.hide();
need_link.hide();
} else if (target_type.val() == "OTHER") {
other.show();
user.hide();
club.hide();
club_account.hide();
company.hide();
need_link.hide();
} else {
company.hide();
user.hide();
club_account.hide();
club.hide();
other.hide();
need_link.hide();
}
}
update_targets();
target_type.change(update_targets);
} );
</script>
</div>
{% endblock %}

View File

@ -1,27 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Simplified type list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
<p>
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
{% trans %}Simplified types{% endtrans %}
</p>
<hr>
<p><a href="{{ url('accounting:simple_type_new') }}">{% trans %}New simplified type{% endtrans %}</a></p>
{% if simplifiedaccountingtype_list %}
<h3>{% trans %}Simplified type list{% endtrans %}</h3>
<ul>
{% for a in simplifiedaccountingtype_list %}
<li><a href="{{ url('accounting:simple_type_edit', type_id=a.id) }}">{{ a }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no types in this website.{% endtrans %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,292 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import date, timedelta
from django.test import TestCase
from django.urls import reverse
from accounting.models import (
AccountingType,
GeneralJournal,
Label,
Operation,
SimplifiedAccountingType,
)
from core.models import User
class TestRefoundAccount(TestCase):
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
# reffil skia's account
cls.skia.customer.amount = 800
cls.skia.customer.save()
cls.refound_account_url = reverse("accounting:refound_account")
def test_permission_denied(self):
self.client.force_login(User.objects.get(username="guy"))
response_post = self.client.post(
self.refound_account_url, {"user": self.skia.id}
)
response_get = self.client.get(self.refound_account_url)
assert response_get.status_code == 403
assert response_post.status_code == 403
def test_root_granteed(self):
self.client.force_login(User.objects.get(username="root"))
response = self.client.post(self.refound_account_url, {"user": self.skia.id})
self.assertRedirects(response, self.refound_account_url)
self.skia.refresh_from_db()
response = self.client.get(self.refound_account_url)
assert response.status_code == 200
assert '<form action="" method="post">' in str(response.content)
assert self.skia.customer.amount == 0
def test_comptable_granteed(self):
self.client.force_login(User.objects.get(username="comptable"))
response = self.client.post(self.refound_account_url, {"user": self.skia.id})
self.assertRedirects(response, self.refound_account_url)
self.skia.refresh_from_db()
response = self.client.get(self.refound_account_url)
assert response.status_code == 200
assert '<form action="" method="post">' in str(response.content)
assert self.skia.customer.amount == 0
class TestJournal(TestCase):
@classmethod
def setUpTestData(cls):
cls.journal = GeneralJournal.objects.get(id=1)
def test_permission_granted(self):
self.client.force_login(User.objects.get(username="comptable"))
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
assert response_get.status_code == 200
assert "<td>M\\xc3\\xa9thode de paiement</td>" in str(response_get.content)
def test_permission_not_granted(self):
self.client.force_login(User.objects.get(username="skia"))
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
assert response_get.status_code == 403
assert "<td>M\xc3\xa9thode de paiement</td>" not in str(response_get.content)
class TestOperation(TestCase):
def setUp(self):
self.tomorrow_formatted = (date.today() + timedelta(days=1)).strftime(
"%d/%m/%Y"
)
self.journal = GeneralJournal.objects.filter(id=1).first()
self.skia = User.objects.filter(username="skia").first()
at = AccountingType(
code="443", label="Ce code n'existe pas", movement_type="CREDIT"
)
at.save()
label = Label.objects.create(club_account=self.journal.club_account, name="bob")
self.client.force_login(User.objects.get(username="comptable"))
self.op1 = Operation(
journal=self.journal,
date=date.today(),
amount=1,
remark="Test bilan",
mode="CASH",
done=True,
label=label,
accounting_type=at,
target_type="USER",
target_id=self.skia.id,
)
self.op1.save()
self.op2 = Operation(
journal=self.journal,
date=date.today(),
amount=2,
remark="Test bilan",
mode="CASH",
done=True,
label=label,
accounting_type=at,
target_type="USER",
target_id=self.skia.id,
)
self.op2.save()
def test_new_operation(self):
at = AccountingType.objects.get(code="604")
response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]),
{
"amount": 30,
"remark": "Un gros test",
"journal": self.journal.id,
"target_type": "OTHER",
"target_id": "",
"target_label": "Le fantome de la nuit",
"date": self.tomorrow_formatted,
"mode": "CASH",
"cheque_number": "",
"invoice": "",
"simpleaccounting_type": "",
"accounting_type": at.id,
"label": "",
"done": False,
},
)
self.assertFalse(response.status_code == 403)
self.assertTrue(
self.journal.operations.filter(
target_label="Le fantome de la nuit"
).exists()
)
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
self.assertTrue("<td>Le fantome de la nuit</td>" in str(response_get.content))
def test_bad_new_operation(self):
AccountingType.objects.get(code="604")
response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]),
{
"amount": 30,
"remark": "Un gros test",
"journal": self.journal.id,
"target_type": "OTHER",
"target_id": "",
"target_label": "Le fantome de la nuit",
"date": self.tomorrow_formatted,
"mode": "CASH",
"cheque_number": "",
"invoice": "",
"simpleaccounting_type": "",
"accounting_type": "",
"label": "",
"done": False,
},
)
self.assertTrue(
"Vous devez fournir soit un type comptable simplifi\\xc3\\xa9 ou un type comptable standard"
in str(response.content)
)
def test_new_operation_not_authorized(self):
self.client.force_login(self.skia)
at = AccountingType.objects.filter(code="604").first()
response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]),
{
"amount": 30,
"remark": "Un gros test",
"journal": self.journal.id,
"target_type": "OTHER",
"target_id": "",
"target_label": "Le fantome du jour",
"date": self.tomorrow_formatted,
"mode": "CASH",
"cheque_number": "",
"invoice": "",
"simpleaccounting_type": "",
"accounting_type": at.id,
"label": "",
"done": False,
},
)
self.assertTrue(response.status_code == 403)
self.assertFalse(
self.journal.operations.filter(target_label="Le fantome du jour").exists()
)
def test_operation_simple_accounting(self):
sat = SimplifiedAccountingType.objects.all().first()
response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]),
{
"amount": 23,
"remark": "Un gros test",
"journal": self.journal.id,
"target_type": "OTHER",
"target_id": "",
"target_label": "Le fantome de l'aurore",
"date": self.tomorrow_formatted,
"mode": "CASH",
"cheque_number": "",
"invoice": "",
"simpleaccounting_type": sat.id,
"accounting_type": "",
"label": "",
"done": False,
},
)
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])
)
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"]
== AccountingType.objects.filter(code=6).values("id").first()["id"]
)
def test_nature_statement(self):
response = self.client.get(
reverse("accounting:journal_nature_statement", args=[self.journal.id])
)
self.assertContains(response, "bob (Troll Penché) : 3.00", status_code=200)
def test_person_statement(self):
response = self.client.get(
reverse("accounting:journal_person_statement", args=[self.journal.id])
)
self.assertContains(response, "Total : 5575.72", status_code=200)
self.assertContains(response, "Total : 71.42")
content = response.content.decode()
self.assertInHTML(
"""<td><a href="/user/1/">S&#39; Kia</a></td><td>3.00</td>""", content
)
self.assertInHTML(
"""<td><a href="/user/1/">S&#39; Kia</a></td><td>823.00</td>""", content
)
def test_accounting_statement(self):
response = self.client.get(
reverse("accounting:journal_accounting_statement", args=[self.journal.id])
)
assert response.status_code == 200
self.assertInHTML(
"""
<tr>
<td>443 - Crédit - Ce code n&#39;existe pas</td>
<td>3.00</td>
</tr>""",
response.content.decode(),
)
self.assertContains(
response,
"""
<p><strong>Montant : </strong>-5504.30 €</p>
<p><strong>Montant effectif: </strong>-5504.30 €</p>""",
)

View File

@ -1,173 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.urls import path
from accounting.views import (
AccountingTypeCreateView,
AccountingTypeEditView,
AccountingTypeListView,
BankAccountCreateView,
BankAccountDeleteView,
BankAccountDetailView,
BankAccountEditView,
BankAccountListView,
ClubAccountCreateView,
ClubAccountDeleteView,
ClubAccountDetailView,
ClubAccountEditView,
CompanyCreateView,
CompanyEditView,
CompanyListView,
JournalAccountingStatementView,
JournalCreateView,
JournalDeleteView,
JournalDetailView,
JournalEditView,
JournalNatureStatementView,
JournalPersonStatementView,
LabelCreateView,
LabelDeleteView,
LabelEditView,
LabelListView,
OperationCreateView,
OperationEditView,
OperationPDFView,
RefoundAccountView,
SimplifiedAccountingTypeCreateView,
SimplifiedAccountingTypeEditView,
SimplifiedAccountingTypeListView,
)
urlpatterns = [
# Accounting types
path(
"simple_type/",
SimplifiedAccountingTypeListView.as_view(),
name="simple_type_list",
),
path(
"simple_type/create/",
SimplifiedAccountingTypeCreateView.as_view(),
name="simple_type_new",
),
path(
"simple_type/<int:type_id>/edit/",
SimplifiedAccountingTypeEditView.as_view(),
name="simple_type_edit",
),
# Accounting types
path("type/", AccountingTypeListView.as_view(), name="type_list"),
path("type/create/", AccountingTypeCreateView.as_view(), name="type_new"),
path(
"type/<int:type_id>/edit/",
AccountingTypeEditView.as_view(),
name="type_edit",
),
# Bank accounts
path("", BankAccountListView.as_view(), name="bank_list"),
path("bank/create", BankAccountCreateView.as_view(), name="bank_new"),
path(
"bank/<int:b_account_id>/",
BankAccountDetailView.as_view(),
name="bank_details",
),
path(
"bank/<int:b_account_id>/edit/",
BankAccountEditView.as_view(),
name="bank_edit",
),
path(
"bank/<int:b_account_id>/delete/",
BankAccountDeleteView.as_view(),
name="bank_delete",
),
# Club accounts
path("club/create/", ClubAccountCreateView.as_view(), name="club_new"),
path(
"club/<int:c_account_id>/",
ClubAccountDetailView.as_view(),
name="club_details",
),
path(
"club/<int:c_account_id>/edit/",
ClubAccountEditView.as_view(),
name="club_edit",
),
path(
"club/<int:c_account_id>/delete/",
ClubAccountDeleteView.as_view(),
name="club_delete",
),
# Journals
path("journal/create/", JournalCreateView.as_view(), name="journal_new"),
path(
"journal/<int:j_id>/",
JournalDetailView.as_view(),
name="journal_details",
),
path(
"journal/<int:j_id>/edit/",
JournalEditView.as_view(),
name="journal_edit",
),
path(
"journal/<int:j_id>/delete/",
JournalDeleteView.as_view(),
name="journal_delete",
),
path(
"journal/<int:j_id>/statement/nature/",
JournalNatureStatementView.as_view(),
name="journal_nature_statement",
),
path(
"journal/<int:j_id>/statement/person/",
JournalPersonStatementView.as_view(),
name="journal_person_statement",
),
path(
"journal/<int:j_id>/statement/accounting/",
JournalAccountingStatementView.as_view(),
name="journal_accounting_statement",
),
# Operations
path(
"operation/create/<int:j_id>/",
OperationCreateView.as_view(),
name="op_new",
),
path("operation/<int:op_id>/", OperationEditView.as_view(), name="op_edit"),
path("operation/<int:op_id>/pdf/", OperationPDFView.as_view(), name="op_pdf"),
# Companies
path("company/list/", CompanyListView.as_view(), name="co_list"),
path("company/create/", CompanyCreateView.as_view(), name="co_new"),
path("company/<int:co_id>/", CompanyEditView.as_view(), name="co_edit"),
# Labels
path("label/new/", LabelCreateView.as_view(), name="label_new"),
path(
"label/<int:clubaccount_id>/",
LabelListView.as_view(),
name="label_list",
),
path("label/<int:label_id>/edit/", LabelEditView.as_view(), name="label_edit"),
path(
"label/<int:label_id>/delete/",
LabelDeleteView.as_view(),
name="label_delete",
),
# User account
path("refound/account/", RefoundAccountView.as_view(), name="refound_account"),
]

View File

@ -1,896 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
import collections
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Sum
from django.forms import HiddenInput
from django.forms.models import modelform_factory
from django.http import HttpResponse
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from accounting.models import (
AccountingType,
BankAccount,
ClubAccount,
Company,
GeneralJournal,
Label,
Operation,
SimplifiedAccountingType,
)
from accounting.widgets.select import (
AutoCompleteSelectClubAccount,
AutoCompleteSelectCompany,
)
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import User
from core.views.forms import SelectDate, SelectFile
from core.views.mixins import TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling
# Main accounting view
class BankAccountListView(CanViewMixin, ListView):
"""A list view for the admins."""
model = BankAccount
template_name = "accounting/bank_account_list.jinja"
ordering = ["name"]
# Simplified accounting types
class SimplifiedAccountingTypeListView(CanViewMixin, ListView):
"""A list view for the admins."""
model = SimplifiedAccountingType
template_name = "accounting/simplifiedaccountingtype_list.jinja"
class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
"""An edit view for the admins."""
model = SimplifiedAccountingType
pk_url_kwarg = "type_id"
fields = ["label", "accounting_type"]
template_name = "core/edit.jinja"
class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins)."""
model = SimplifiedAccountingType
fields = ["label", "accounting_type"]
template_name = "core/create.jinja"
permission_required = "accounting.add_simplifiedaccountingtype"
# Accounting types
class AccountingTypeListView(CanViewMixin, ListView):
"""A list view for the admins."""
model = AccountingType
template_name = "accounting/accountingtype_list.jinja"
class AccountingTypeEditView(CanViewMixin, UpdateView):
"""An edit view for the admins."""
model = AccountingType
pk_url_kwarg = "type_id"
fields = ["code", "label", "movement_type"]
template_name = "core/edit.jinja"
class AccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins)."""
model = AccountingType
fields = ["code", "label", "movement_type"]
template_name = "core/create.jinja"
permission_required = "accounting.add_accountingtype"
# BankAccount views
class BankAccountEditView(CanViewMixin, UpdateView):
"""An edit view for the admins."""
model = BankAccount
pk_url_kwarg = "b_account_id"
fields = ["name", "iban", "number", "club"]
template_name = "core/edit.jinja"
class BankAccountDetailView(CanViewMixin, DetailView):
"""A detail view, listing every club account."""
model = BankAccount
pk_url_kwarg = "b_account_id"
template_name = "accounting/bank_account_details.jinja"
class BankAccountCreateView(CanCreateMixin, CreateView):
"""Create a bank account (for the admins)."""
model = BankAccount
fields = ["name", "club", "iban", "number"]
template_name = "core/create.jinja"
class BankAccountDeleteView(
CanEditPropMixin, DeleteView
): # TODO change Delete to Close
"""Delete a bank account (for the admins)."""
model = BankAccount
pk_url_kwarg = "b_account_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("accounting:bank_list")
# ClubAccount views
class ClubAccountEditView(CanViewMixin, UpdateView):
"""An edit view for the admins."""
model = ClubAccount
pk_url_kwarg = "c_account_id"
fields = ["name", "club", "bank_account"]
template_name = "core/edit.jinja"
class ClubAccountDetailView(CanViewMixin, DetailView):
"""A detail view, listing every journal."""
model = ClubAccount
pk_url_kwarg = "c_account_id"
template_name = "accounting/club_account_details.jinja"
class ClubAccountCreateView(CanCreateMixin, CreateView):
"""Create a club account (for the admins)."""
model = ClubAccount
fields = ["name", "club", "bank_account"]
template_name = "core/create.jinja"
def get_initial(self):
ret = super().get_initial()
if "parent" in self.request.GET:
obj = BankAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None:
ret["bank_account"] = obj.id
return ret
class ClubAccountDeleteView(
CanEditPropMixin, DeleteView
): # TODO change Delete to Close
"""Delete a club account (for the admins)."""
model = ClubAccount
pk_url_kwarg = "c_account_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("accounting:bank_list")
# Journal views
class JournalTabsMixin(TabedViewMixin):
def get_tabs_title(self):
return _("Journal")
def get_list_of_tabs(self):
return [
{
"url": reverse(
"accounting:journal_details", kwargs={"j_id": self.object.id}
),
"slug": "journal",
"name": _("Journal"),
},
{
"url": reverse(
"accounting:journal_nature_statement",
kwargs={"j_id": self.object.id},
),
"slug": "nature_statement",
"name": _("Statement by nature"),
},
{
"url": reverse(
"accounting:journal_person_statement",
kwargs={"j_id": self.object.id},
),
"slug": "person_statement",
"name": _("Statement by person"),
},
{
"url": reverse(
"accounting:journal_accounting_statement",
kwargs={"j_id": self.object.id},
),
"slug": "accounting_statement",
"name": _("Accounting statement"),
},
]
class JournalCreateView(CanCreateMixin, CreateView):
"""Create a general journal."""
model = GeneralJournal
form_class = modelform_factory(
GeneralJournal,
fields=["name", "start_date", "club_account"],
widgets={"start_date": SelectDate},
)
template_name = "core/create.jinja"
def get_initial(self):
ret = super().get_initial()
if "parent" in self.request.GET:
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None:
ret["club_account"] = obj.id
return ret
class JournalDetailView(JournalTabsMixin, CanViewMixin, DetailView):
"""A detail view, listing every operation."""
model = GeneralJournal
pk_url_kwarg = "j_id"
template_name = "accounting/journal_details.jinja"
current_tab = "journal"
class JournalEditView(CanEditMixin, UpdateView):
"""Update a general journal."""
model = GeneralJournal
pk_url_kwarg = "j_id"
fields = ["name", "start_date", "end_date", "club_account", "closed"]
template_name = "core/edit.jinja"
class JournalDeleteView(CanEditPropMixin, DeleteView):
"""Delete a club account (for the admins)."""
model = GeneralJournal
pk_url_kwarg = "j_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("accounting:club_details")
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.operations.count() == 0:
return super().dispatch(request, *args, **kwargs)
else:
raise PermissionDenied
# Operation views
class OperationForm(forms.ModelForm):
class Meta:
model = Operation
fields = [
"amount",
"remark",
"journal",
"target_type",
"target_id",
"target_label",
"date",
"mode",
"cheque_number",
"invoice",
"simpleaccounting_type",
"accounting_type",
"label",
"done",
]
widgets = {
"journal": HiddenInput,
"target_id": HiddenInput,
"date": SelectDate,
"invoice": SelectFile,
}
user = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
club_account = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClubAccount,
queryset=ClubAccount.objects.all(),
)
club = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectClub,
queryset=Club.objects.all(),
)
company = forms.ModelChoiceField(
help_text=None,
required=False,
widget=AutoCompleteSelectCompany,
queryset=Company.objects.all(),
)
need_link = forms.BooleanField(
label=_("Link this operation to the target account"),
required=False,
initial=False,
)
def __init__(self, *args, **kwargs):
club_account = kwargs.pop("club_account", None)
super().__init__(*args, **kwargs)
if club_account:
self.fields["label"].queryset = club_account.labels.order_by("name").all()
if self.instance.target_type == "USER":
self.fields["user"].initial = self.instance.target_id
elif self.instance.target_type == "ACCOUNT":
self.fields["club_account"].initial = self.instance.target_id
elif self.instance.target_type == "CLUB":
self.fields["club"].initial = self.instance.target_id
elif self.instance.target_type == "COMPANY":
self.fields["company"].initial = self.instance.target_id
def clean(self):
self.cleaned_data = super().clean()
if "target_type" in self.cleaned_data:
if (
self.cleaned_data.get("user") is None
and self.cleaned_data.get("club") is None
and self.cleaned_data.get("club_account") is None
and self.cleaned_data.get("company") is None
and self.cleaned_data.get("target_label") == ""
):
self.add_error(
"target_type", ValidationError(_("The target must be set."))
)
else:
if self.cleaned_data["target_type"] == "USER":
self.cleaned_data["target_id"] = self.cleaned_data["user"].id
elif self.cleaned_data["target_type"] == "ACCOUNT":
self.cleaned_data["target_id"] = self.cleaned_data[
"club_account"
].id
elif self.cleaned_data["target_type"] == "CLUB":
self.cleaned_data["target_id"] = self.cleaned_data["club"].id
elif self.cleaned_data["target_type"] == "COMPANY":
self.cleaned_data["target_id"] = self.cleaned_data["company"].id
if self.cleaned_data.get("amount") is None:
self.add_error("amount", ValidationError(_("The amount must be set.")))
return self.cleaned_data
def save(self):
ret = super().save()
if (
self.instance.target_type == "ACCOUNT"
and not self.instance.linked_operation
and self.instance.target.has_open_journal()
and self.cleaned_data["need_link"]
):
inst = self.instance
club_account = inst.target
acc_type = (
AccountingType.objects.exclude(movement_type="NEUTRAL")
.exclude(movement_type=inst.accounting_type.movement_type)
.order_by("code")
.first()
) # Select a random opposite accounting type
op = Operation(
journal=club_account.get_open_journal(),
amount=inst.amount,
date=inst.date,
remark=inst.remark,
mode=inst.mode,
cheque_number=inst.cheque_number,
invoice=inst.invoice,
done=False, # Has to be checked by hand
simpleaccounting_type=None,
accounting_type=acc_type,
target_type="ACCOUNT",
target_id=inst.journal.club_account.id,
target_label="",
linked_operation=inst,
)
op.save()
self.instance.linked_operation = op
self.save()
return ret
class OperationCreateView(CanCreateMixin, CreateView):
"""Create an operation."""
model = Operation
form_class = OperationForm
template_name = "accounting/operation_edit.jinja"
def get_form(self, form_class=None):
self.journal = GeneralJournal.objects.filter(id=self.kwargs["j_id"]).first()
ca = self.journal.club_account if self.journal else None
return self.form_class(club_account=ca, **self.get_form_kwargs())
def get_initial(self):
ret = super().get_initial()
if self.journal is not None:
ret["journal"] = self.journal.id
return ret
def get_context_data(self, **kwargs):
"""Add journal to the context."""
kwargs = super().get_context_data(**kwargs)
if self.journal:
kwargs["object"] = self.journal
return kwargs
class OperationEditView(CanEditMixin, UpdateView):
"""An edit view, working as detail for the moment."""
model = Operation
pk_url_kwarg = "op_id"
form_class = OperationForm
template_name = "accounting/operation_edit.jinja"
def get_context_data(self, **kwargs):
"""Add journal to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["object"] = self.object.journal
return kwargs
class OperationPDFView(CanViewMixin, DetailView):
"""Display the PDF of a given operation."""
model = Operation
pk_url_kwarg = "op_id"
def get(self, request, *args, **kwargs):
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import cm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from reportlab.platypus import Table, TableStyle
pdfmetrics.registerFont(TTFont("DejaVu", "DejaVuSerif.ttf"))
self.object = self.get_object()
amount = self.object.amount
remark = self.object.remark
nature = self.object.accounting_type.movement_type
num = self.object.number
date = self.object.date
mode = self.object.mode
club_name = self.object.journal.club_account.name
ti = self.object.journal.name
op_label = self.object.label
club_address = self.object.journal.club_account.club.address
id_op = self.object.id
if self.object.target_type == "OTHER":
target = self.object.target_label
else:
target = self.object.target.get_display_name()
response = HttpResponse(content_type="application/pdf")
response["Content-Disposition"] = 'filename="op-%d(%s_on_%s).pdf"' % (
num,
ti,
club_name,
)
p = canvas.Canvas(response)
p.setFont("DejaVu", 12)
p.setTitle("%s %d" % (_("Operation"), num))
width, height = letter
im = ImageReader("core/static/core/img/logo.jpg")
iw, ih = im.getSize()
p.drawImage(im, 40, height - 50, width=iw / 2, height=ih / 2)
labelStr = [["%s %s - %s %s" % (_("Journal"), ti, _("Operation"), num)]]
label = Table(labelStr, colWidths=[150], rowHeights=[20])
label.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "RIGHT")]))
w, h = label.wrapOn(label, 0, 0)
label.drawOn(p, width - 180, height)
p.drawString(
90, height - 100, _("Financial proof: ") + "OP%010d" % (id_op)
) # Justificatif du libellé
p.drawString(
90, height - 130, _("Club: %(club_name)s") % ({"club_name": club_name})
)
p.drawString(
90,
height - 160,
_("Label: %(op_label)s")
% {"op_label": op_label if op_label is not None else ""},
)
p.drawString(90, height - 190, _("Date: %(date)s") % {"date": date})
data = []
data += [
["%s" % (_("Credit").upper() if nature == "CREDIT" else _("Debit").upper())]
]
data += [[_("Amount: %(amount).2f") % {"amount": amount}]]
payment_mode = ""
for m in settings.SITH_ACCOUNTING_PAYMENT_METHOD:
if m[0] == mode:
payment_mode += "[\u00d7]"
else:
payment_mode += "[ ]"
payment_mode += " %s\n" % (m[1])
data += [[payment_mode]]
data += [
[
"%s : %s"
% (_("Debtor") if nature == "CREDIT" else _("Creditor"), target),
"",
]
]
data += [["%s \n%s" % (_("Comment:"), remark)]]
t = Table(
data, colWidths=[(width - 90 * 2) / 2] * 2, rowHeights=[20, 20, 70, 20, 80]
)
t.setStyle(
TableStyle(
[
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (-2, -1), (-1, -1), "TOP"),
("VALIGN", (0, 0), (-1, -2), "MIDDLE"),
("INNERGRID", (0, 0), (-1, -1), 0.25, colors.black),
("SPAN", (0, 0), (1, 0)), # line DEBIT/CREDIT
("SPAN", (0, 1), (1, 1)), # line amount
("SPAN", (-2, -1), (-1, -1)), # line comment
("SPAN", (0, -2), (-1, -2)), # line creditor/debtor
("SPAN", (0, 2), (1, 2)), # line payment_mode
("ALIGN", (0, 2), (1, 2), "LEFT"), # line payment_mode
("ALIGN", (-2, -1), (-1, -1), "LEFT"),
("BOX", (0, 0), (-1, -1), 0.25, colors.black),
]
)
)
signature = []
signature += [[_("Signature:")]]
tSig = Table(signature, colWidths=[(width - 90 * 2)], rowHeights=[80])
tSig.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.25, colors.black),
]
)
)
w, h = tSig.wrapOn(p, 0, 0)
tSig.drawOn(p, 90, 200)
w, h = t.wrapOn(p, 0, 0)
t.drawOn(p, 90, 350)
p.drawCentredString(10.5 * cm, 2 * cm, club_name)
p.drawCentredString(10.5 * cm, 1 * cm, club_address)
p.showPage()
p.save()
return response
class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
"""Display a statement sorted by labels."""
model = GeneralJournal
pk_url_kwarg = "j_id"
template_name = "accounting/journal_statement_nature.jinja"
current_tab = "nature_statement"
def statement(self, queryset, movement_type):
ret = collections.OrderedDict()
statement = collections.OrderedDict()
total_sum = 0
for sat in [
None,
*list(SimplifiedAccountingType.objects.order_by("label")),
]:
amount = queryset.filter(
accounting_type__movement_type=movement_type, simpleaccounting_type=sat
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
label = sat.label if sat is not None else ""
if amount:
total_sum += amount
statement[label] = amount
ret[movement_type] = statement
ret[movement_type + "_sum"] = total_sum
return ret
def big_statement(self):
label_list = (
self.object.operations.order_by("label").values_list("label").distinct()
)
labels = Label.objects.filter(id__in=label_list).all()
statement = collections.OrderedDict()
gen_statement = collections.OrderedDict()
no_label_statement = collections.OrderedDict()
gen_statement.update(self.statement(self.object.operations.all(), "CREDIT"))
gen_statement.update(self.statement(self.object.operations.all(), "DEBIT"))
statement[_("General statement")] = gen_statement
no_label_statement.update(
self.statement(self.object.operations.filter(label=None).all(), "CREDIT")
)
no_label_statement.update(
self.statement(self.object.operations.filter(label=None).all(), "DEBIT")
)
statement[_("No label operations")] = no_label_statement
for label in labels:
l_stmt = collections.OrderedDict()
journals = self.object.operations.filter(label=label).all()
l_stmt.update(self.statement(journals, "CREDIT"))
l_stmt.update(self.statement(journals, "DEBIT"))
statement[label] = l_stmt
return statement
def get_context_data(self, **kwargs):
"""Add infos to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["statement"] = self.big_statement()
return kwargs
class JournalPersonStatementView(JournalTabsMixin, CanViewMixin, DetailView):
"""Calculate a dictionary with operation target and sum of operations."""
model = GeneralJournal
pk_url_kwarg = "j_id"
template_name = "accounting/journal_statement_person.jinja"
current_tab = "person_statement"
def sum_by_target(self, target_id, target_type, movement_type):
return self.object.operations.filter(
accounting_type__movement_type=movement_type,
target_id=target_id,
target_type=target_type,
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
def statement(self, movement_type):
statement = collections.OrderedDict()
for op in (
self.object.operations.filter(accounting_type__movement_type=movement_type)
.order_by("target_type", "target_id")
.distinct()
):
statement[op.target] = self.sum_by_target(
op.target_id, op.target_type, movement_type
)
return statement
def total(self, movement_type):
return sum(self.statement(movement_type).values())
def get_context_data(self, **kwargs):
"""Add journal to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["credit_statement"] = self.statement("CREDIT")
kwargs["debit_statement"] = self.statement("DEBIT")
kwargs["total_credit"] = self.total("CREDIT")
kwargs["total_debit"] = self.total("DEBIT")
return kwargs
class JournalAccountingStatementView(JournalTabsMixin, CanViewMixin, DetailView):
"""Calculate a dictionary with operation type and sum of operations."""
model = GeneralJournal
pk_url_kwarg = "j_id"
template_name = "accounting/journal_statement_accounting.jinja"
current_tab = "accounting_statement"
def statement(self):
statement = collections.OrderedDict()
for at in AccountingType.objects.order_by("code").all():
sum_by_type = self.object.operations.filter(
accounting_type__code__startswith=at.code
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
if sum_by_type:
statement[at] = sum_by_type
return statement
def get_context_data(self, **kwargs):
"""Add journal to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["statement"] = self.statement()
return kwargs
# Company views
class CompanyListView(CanViewMixin, ListView):
model = Company
template_name = "accounting/co_list.jinja"
class CompanyCreateView(CanCreateMixin, CreateView):
"""Create a company."""
model = Company
fields = ["name"]
template_name = "core/create.jinja"
success_url = reverse_lazy("accounting:co_list")
class CompanyEditView(CanCreateMixin, UpdateView):
"""Edit a company."""
model = Company
pk_url_kwarg = "co_id"
fields = ["name"]
template_name = "core/edit.jinja"
success_url = reverse_lazy("accounting:co_list")
# Label views
class LabelListView(CanViewMixin, DetailView):
model = ClubAccount
pk_url_kwarg = "clubaccount_id"
template_name = "accounting/label_list.jinja"
class LabelCreateView(
CanCreateMixin, CreateView
): # FIXME we need to check the rights before creating the object
model = Label
form_class = modelform_factory(
Label, fields=["name", "club_account"], widgets={"club_account": HiddenInput}
)
template_name = "core/create.jinja"
def get_initial(self):
ret = super().get_initial()
if "parent" in self.request.GET:
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None:
ret["club_account"] = obj.id
return ret
class LabelEditView(CanEditMixin, UpdateView):
model = Label
pk_url_kwarg = "label_id"
fields = ["name"]
template_name = "core/edit.jinja"
class LabelDeleteView(CanEditMixin, DeleteView):
model = Label
pk_url_kwarg = "label_id"
template_name = "core/delete_confirm.jinja"
def get_success_url(self):
return self.object.get_absolute_url()
class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField(
label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
class RefoundAccountView(FormView):
"""Create a selling with the same amount than the current user money."""
template_name = "accounting/refound_account.jinja"
form_class = CloseCustomerAccountForm
def permission(self, user):
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
else:
raise PermissionDenied
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if self.permission(request.user):
return res
def post(self, request, *arg, **kwargs):
self.operator = request.user
if self.permission(request.user):
return super().post(self, request, *arg, **kwargs)
def form_valid(self, form):
self.customer = form.cleaned_data["user"]
self.create_selling()
return super().form_valid(form)
def get_success_url(self):
return reverse("accounting:refound_account")
def create_selling(self):
with transaction.atomic():
uprice = self.customer.customer.amount
refound_club_counter = Counter.objects.get(
id=settings.SITH_COUNTER_REFOUND_ID
)
refound_club = refound_club_counter.club
s = Selling(
label=_("Refound account"),
unit_price=uprice,
quantity=1,
seller=self.operator,
customer=self.customer.customer,
club=refound_club,
counter=refound_club_counter,
product=Product.objects.get(id=settings.SITH_PRODUCT_REFOUND_ID),
)
s.save()

View File

@ -1,39 +0,0 @@
from pydantic import TypeAdapter
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
_js = ["bundled/accounting/components/ajax-select-index.ts"]
class AutoCompleteSelectClubAccount(AutoCompleteSelect):
component_name = "club-account-ajax-select"
model = ClubAccount
adapter = TypeAdapter(list[ClubAccountSchema])
js = _js
class AutoCompleteSelectMultipleClubAccount(AutoCompleteSelectMultiple):
component_name = "club-account-ajax-select"
model = ClubAccount
adapter = TypeAdapter(list[ClubAccountSchema])
js = _js
class AutoCompleteSelectCompany(AutoCompleteSelect):
component_name = "company-ajax-select"
model = Company
adapter = TypeAdapter(list[CompanySchema])
js = _js
class AutoCompleteSelectMultipleCompany(AutoCompleteSelectMultiple):
component_name = "company-ajax-select"
model = Company
adapter = TypeAdapter(list[CompanySchema])
js = _js

View File

@ -1,5 +1,3 @@
import re
from django import forms
from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _
@ -7,12 +5,18 @@ from django.utils.translation import gettext_lazy as _
from antispam.models import ToxicDomain
class AntiSpamEmailValidator(EmailValidator):
def __call__(self, value: str):
super().__call__(value)
domain_part = value.rsplit("@", 1)[1]
if ToxicDomain.objects.filter(domain=domain_part).exists():
raise forms.ValidationError(_("Email domain is not allowed."))
validate_antispam_email = AntiSpamEmailValidator()
class AntiSpamEmailField(forms.EmailField):
"""An email field that email addresses with a known toxic domain."""
def run_validators(self, value: str):
super().run_validators(value)
# Domain part should exist since email validation is guaranteed to run first
domain = re.search(EmailValidator.domain_regex, value)
if ToxicDomain.objects.filter(domain=domain[0]).exists():
raise forms.ValidationError(_("Email domain is not allowed."))
default_validators = [validate_antispam_email]

View File

@ -34,7 +34,7 @@ class Command(BaseCommand):
f"Source {provider} responded with code {res.status_code}"
)
continue
domains |= set(res.content.decode().splitlines())
domains |= set(res.text.splitlines())
return domains
def _update_domains(self, domains: set[str]):

View File

@ -19,8 +19,8 @@ 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")
list_display = ("name", "slug_name", "parent", "is_active")
search_fields = ("name", "slug_name")
autocomplete_fields = (
"parent",
"board_group",

View File

@ -24,23 +24,32 @@
from django import forms
from django.conf import settings
from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower
from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User
from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectMultipleUser
from counter.models import Counter
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
from counter.models import Counter, Selling
class ClubEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = Club
fields = ["address", "logo", "short_description"]
widgets = {"short_description": forms.Textarea()}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["short_description"].widget = forms.Textarea()
class ClubAdminEditForm(ClubEditForm):
admin_fields = ["name", "parent", "is_active"]
class Meta(ClubEditForm.Meta):
fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields]
class MailingForm(forms.Form):
@ -152,12 +161,20 @@ class SellingsForm(forms.Form):
label=_("End date"), widget=SelectDateTime, required=False
)
counters = forms.ModelMultipleChoiceField(
Counter.objects.order_by("name").all(), label=_("Counter"), required=False
)
def __init__(self, club, *args, **kwargs):
super().__init__(*args, **kwargs)
counters_qs = (
Counter.objects.filter(
Q(club=club)
| Q(products__club=club)
| Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
)
.distinct()
.order_by(Lower("name"))
)
self.fields["counters"] = forms.ModelMultipleChoiceField(
counters_qs, label=_("Counter"), required=False
)
self.fields["products"] = forms.ModelMultipleChoiceField(
club.products.order_by("name").filter(archived=False).all(),
label=_("Products"),
@ -189,9 +206,7 @@ class ClubMemberForm(forms.Form):
self.request_user = kwargs.pop("request_user")
self.club_members = kwargs.pop("club_members", None)
if not self.club_members:
self.club_members = (
self.club.members.filter(end_date=None).order_by("-role").all()
)
self.club_members = self.club.members.ongoing().order_by("-role").all()
self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs)

View File

@ -1,10 +1,9 @@
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import club.models
class Migration(migrations.Migration):
dependencies = [("club", "0010_auto_20170912_2028")]
@ -15,7 +14,7 @@ class Migration(migrations.Migration):
name="owner_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
default=club.models.get_default_owner_group,
default=lambda: settings.SITH_ROOT_USER_ID,
related_name="owned_club",
to="core.Group",
),

View File

@ -0,0 +1,75 @@
# Generated by Django 4.2.17 on 2025-02-28 20:34
import django.db.models.deletion
from django.db import migrations, models
import core.fields
class Migration(migrations.Migration):
dependencies = [
("core", "0044_alter_userban_options"),
("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
]
operations = [
migrations.AlterModelOptions(name="club", options={"ordering": ["name"]}),
migrations.RenameField(
model_name="club",
old_name="unix_name",
new_name="slug_name",
),
migrations.AlterField(
model_name="club",
name="name",
field=models.CharField(unique=True, max_length=64, verbose_name="name"),
),
migrations.AlterField(
model_name="club",
name="slug_name",
field=models.SlugField(
editable=False, max_length=30, unique=True, verbose_name="slug name"
),
),
migrations.AlterField(
model_name="club",
name="id",
field=models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="club",
name="logo",
field=core.fields.ResizedImageField(
blank=True,
force_format="WEBP",
height=200,
null=True,
upload_to="club_logos",
verbose_name="logo",
width=200,
),
),
migrations.AlterField(
model_name="club",
name="page",
field=models.OneToOneField(
blank=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="club",
to="core.page",
),
),
migrations.AlterField(
model_name="club",
name="short_description",
field=models.CharField(
blank=True,
default="",
help_text="A summary of what your club does. This will be displayed on the club list page.",
max_length=1000,
verbose_name="short description",
),
),
]

View File

@ -26,7 +26,6 @@ from __future__ import annotations
from typing import Iterable, Self
from django.conf import settings
from django.core import validators
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
@ -35,48 +34,43 @@ 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.text import slugify
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
# Create your models here.
# This function prevents generating migration upon settings change
def get_default_owner_group():
return settings.SITH_GROUP_ROOT_ID
class Club(models.Model):
"""The Club class, made as a tree to allow nice tidy organization."""
id = models.AutoField(primary_key=True, db_index=True)
name = models.CharField(_("name"), max_length=64)
name = models.CharField(_("name"), unique=True, max_length=64)
parent = models.ForeignKey(
"Club", related_name="children", null=True, blank=True, on_delete=models.CASCADE
)
unix_name = models.CharField(
_("unix name"),
max_length=30,
unique=True,
validators=[
validators.RegexValidator(
r"^[a-z0-9][a-z0-9._-]*[a-z0-9]$",
_(
"Enter a valid unix name. This value may contain only "
"letters, numbers ./-/_ characters."
),
)
],
error_messages={"unique": _("A club with that unix name already exists.")},
slug_name = models.SlugField(
_("slug name"), max_length=30, unique=True, editable=False
)
logo = models.ImageField(
upload_to="club_logos", verbose_name=_("logo"), null=True, blank=True
logo = ResizedImageField(
upload_to="club_logos",
verbose_name=_("logo"),
null=True,
blank=True,
force_format="WEBP",
height=200,
width=200,
)
is_active = models.BooleanField(_("is active"), default=True)
short_description = models.CharField(
_("short description"), max_length=1000, default="", blank=True, null=True
_("short description"),
max_length=1000,
default="",
blank=True,
help_text=_(
"A summary of what your club does. "
"This will be displayed on the club list page."
),
)
address = models.CharField(_("address"), max_length=254)
home = models.OneToOneField(
@ -88,7 +82,7 @@ class Club(models.Model):
on_delete=models.SET_NULL,
)
page = models.OneToOneField(
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
Page, related_name="club", blank=True, on_delete=models.CASCADE
)
members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT
@ -98,7 +92,7 @@ class Club(models.Model):
)
class Meta:
ordering = ["name", "unix_name"]
ordering = ["name"]
def __str__(self):
return self.name
@ -106,10 +100,12 @@ class Club(models.Model):
@transaction.atomic()
def save(self, *args, **kwargs):
creation = self._state.adding
if (slug := slugify(self.name)[:30]) != self.slug_name:
self.slug_name = slug
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
if self.name != db_club.name:
self.home.name = self.slug_name
self.home.save()
if self.name != db_club.name:
self.board_group.name = f"{self.name} - Bureau"
@ -123,11 +119,9 @@ class Club(models.Model):
self.members_group = Group.objects.create(
name=f"{self.name} - Membres", is_manually_manageable=False
)
super().save(*args, **kwargs)
if creation:
self.make_home()
self.make_page()
cache.set(f"sith_club_{self.unix_name}", self)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("club:club_view", kwargs={"club_id": self.id})
@ -155,49 +149,37 @@ class Club(models.Model):
def make_home(self) -> None:
if 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:
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
home.save()
self.home = home
self.save()
home_root = SithFile.objects.get(parent=None, name="clubs")
root = User.objects.get(id=settings.SITH_ROOT_USER_ID)
self.home = SithFile.objects.create(
parent=home_root, name=self.slug_name, owner=root
)
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()
if root and club_root:
public = Group.objects.filter(id=settings.SITH_GROUP_PUBLIC_ID).first()
p = Page(name=self.unix_name)
p.parent = club_root
p.save(force_lock=True)
if public:
p.view_groups.add(public)
p.save(force_lock=True)
if self.parent and self.parent.page:
p.parent = self.parent.page
self.page = p
self.save()
elif self.page and self.page.name != self.unix_name:
self.page.unset_lock()
self.page.name = self.unix_name
self.page.save(force_lock=True)
elif (
self.page
and self.parent
and self.parent.page
and self.page.parent != self.parent.page
):
self.page.unset_lock()
page_name = self.slug_name
if not self.page_id:
# Club.page is a OneToOneField, so if we are inside this condition
# then self._meta.state.adding is True.
club_root = Page.objects.get(name=settings.SITH_CLUB_ROOT_PAGE)
public = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
p = Page(name=page_name, parent=club_root)
p.save(force_lock=True)
p.view_groups.add(public)
if self.parent and self.parent.page_id:
p.parent_id = self.parent.page_id
self.page = p
return
self.page.unset_lock()
if self.page.name != page_name:
self.page.name = page_name
elif self.parent and self.parent.page and self.page.parent != self.parent.page:
self.page.parent = self.parent.page
self.page.save(force_lock=True)
self.page.save(force_lock=True)
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}")
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)

View File

@ -4,7 +4,7 @@
{% block content %}
<div id="club_detail">
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.unix_name }}"></div>
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %}
{% if page_revision %}
{{ page_revision|markdown }}

View File

@ -11,7 +11,7 @@
{{ select_all_checkbox("users_old") }}
<p></p>
{% endif %}
<table>
<table id="club_members_table">
<thead>
<tr>
<td>{% trans %}User{% endtrans %}</td>

View File

@ -16,30 +16,13 @@
</ul>
<h4>{% trans %}Counters:{% endtrans %}</h4>
<ul>
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %}
{% for l in Launderette.objects.all() %}
<li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
{% endfor %}
{% elif object.counters.filter(type="OFFICE")|count > 0 %}
{% for c in object.counters.filter(type="OFFICE") %}
<li>{{ c }}:
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
</li>
{% endfor %}
{% endif %}
{% for c in object.counters.filter(type="OFFICE") %}
<li>{{ c }}:
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
</li>
{% endfor %}
</ul>
{% if object.club_account.exists() %}
<h4>{% trans %}Accounting: {% endtrans %}</h4>
<ul>
{% for ca in object.club_account.all() %}
<li><a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca.get_display_name() }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %}
<li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,54 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans name=object %}Edit {{ name }}{% endtrans %}
{% endblock %}
{% block content %}
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.non_field_errors() }}
{% if form.admin_fields %}
{# If the user is admin, display the admin fields,
and explicitly separate them from the non-admin ones,
with some help text.
Non-admin users will only see the regular form fields,
so they don't need thoses explanations #}
<h3>{% trans %}Club properties{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
The following form fields are linked to the core properties of a club.
Only admin users can see and edit them.
{% endtrans %}
</p>
<fieldset class="required margin-bottom">
{% for field_name in form.admin_fields %}
{% set field = form[field_name] %}
<div class="form-group">
{{ field.errors }}
{{ field.label_tag() }}
{{ field }}
</div>
{# Remove the the admin fields from the form.
The remaining non-admin fields will be rendered
at once with a simple {{ form.as_p() }} #}
{% set _ = form.fields.pop(field_name) %}
{% endfor %}
</fieldset>
<h3>{% trans %}Club informations{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
The following form fields are linked to the basic description of a club.
All board members of this club can see and edit them.
{% endtrans %}
</p>
{% endif %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock content %}

View File

@ -1,49 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Club stats{% endtrans %}
{% endblock %}
{% block content %}
{% if club_list %}
<h3>{% trans %}Club stats{% endtrans %}</h3>
<form action="" method="GET">
{% csrf_token %}
<p>
<select name="branch">
{% for b in settings.SITH_PROFILE_DEPARTMENTS %}
<option value="{{ b[0] }}">{{ b[0] }}</option>
{% endfor %}
</select>
</p>
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
</form>
<table>
<thead>
<tr>
<td>Club</td>
<td>Member number</td>
<td>Old member number</td>
</tr>
</thead>
<tbody>
{% for c in club_list.order_by('id') %}
{% set members = c.members.all() %}
{% if request.GET['branch'] %}
{% set members = members.filter(user__department=request.GET['branch']) %}
{% endif %}
<tr>
<td>{{ c.get_display_name() }}</td>
<td>{{ members.filter(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}</td>
<td>{{ members.exclude(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% trans %}There is no club in this website.{% endtrans %}
{% endif %}
{% endblock %}

View File

@ -1,906 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import timedelta
from django.conf import settings
from django.core.cache import cache
from django.test import TestCase
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
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
from sith.settings import SITH_BAR_MANAGER, SITH_MAIN_CLUB_ID
class TestClub(TestCase):
"""Set up data for test cases related to clubs and membership.
The generated dataset is the one created by the populate command,
plus the following modifications :
- `self.club` is a dummy club recreated for each test
- `self.club` has two board members : skia (role 3) and comptable (role 10)
- `self.club` has one regular member : richard
- `self.club` has one former member : sli (who had role 2)
- None of the `self.club` members are in the AE club.
"""
@classmethod
def setUpTestData(cls):
# subscribed users - initial members
cls.skia = User.objects.get(username="skia")
# by default, Skia is in the AE, which creates side effect
cls.skia.memberships.all().delete()
cls.richard = User.objects.get(username="rbatsbak")
cls.comptable = User.objects.get(username="comptable")
cls.sli = User.objects.get(username="sli")
cls.root = User.objects.get(username="root")
# subscribed users - not initial members
cls.krophil = User.objects.get(username="krophil")
cls.subscriber = User.objects.get(username="subscriber")
# old subscriber
cls.old_subscriber = User.objects.get(username="old_subscriber")
# not subscribed
cls.public = User.objects.get(username="public")
cls.ae = Club.objects.filter(pk=SITH_MAIN_CLUB_ID)[0]
cls.club = Club.objects.create(
name="Fake Club",
unix_name="fake-club",
address="5 rue de la République, 90000 Belfort",
)
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
a_month_ago = now() - timedelta(days=30)
yesterday = now() - timedelta(days=1)
Membership.objects.create(
club=cls.club, user=cls.skia, start_date=a_month_ago, role=3
)
Membership.objects.create(club=cls.club, user=cls.richard, role=1)
Membership.objects.create(
club=cls.club, user=cls.comptable, start_date=a_month_ago, role=10
)
# sli was a member but isn't anymore
Membership.objects.create(
club=cls.club,
user=cls.sli,
start_date=a_month_ago,
end_date=yesterday,
role=2,
)
def setUp(self):
cache.clear()
class TestMembershipQuerySet(TestClub):
def test_ongoing(self):
"""Test that the ongoing queryset method returns the memberships that
are not ended.
"""
current_members = list(self.club.members.ongoing().order_by("id"))
expected = [
self.skia.memberships.get(club=self.club),
self.comptable.memberships.get(club=self.club),
self.richard.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert current_members == expected
def test_ongoing_with_membership_ending_today(self):
"""Test that a membership ending the present day is considered as ended."""
today = localdate()
self.richard.memberships.filter(club=self.club).update(end_date=today)
current_members = list(self.club.members.ongoing().order_by("id"))
expected = [
self.skia.memberships.get(club=self.club),
self.comptable.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert current_members == expected
def test_board(self):
"""Test that the board queryset method returns the memberships
of user in the club board.
"""
board_members = list(self.club.members.board().order_by("id"))
expected = [
self.skia.memberships.get(club=self.club),
self.comptable.memberships.get(club=self.club),
# sli is no more member, but he was in the board
self.sli.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert board_members == expected
def test_ongoing_board(self):
"""Test that combining ongoing and board returns users
who are currently board members of the club.
"""
members = list(self.club.members.ongoing().board().order_by("id"))
expected = [
self.skia.memberships.get(club=self.club),
self.comptable.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.skia.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.skia.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
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)
mem_comptable = self.comptable.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
cache.set(
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
)
# should delete the subscriptions of skia and comptable
self.club.members.ongoing().board().delete()
for membership in (mem_skia, mem_comptable):
cached_mem = cache.get(
f"membership_{membership.club_id}_{membership.user_id}"
)
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()).issuperset(club_groups)
user.memberships.all().delete()
assert set(user.groups.all()).isdisjoint(club_groups)
class TestClubModel(TestClub):
def assert_membership_started_today(self, user: User, role: int):
"""Assert that the given membership is active and started today."""
membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None
assert localtime(now()).date() == membership.start_date
assert membership.end_date is None
assert membership.role == role
assert membership.club.get_membership_for(user) == membership
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."""
today = localtime(now()).date()
assert user.memberships.filter(club=self.club, end_date=today).exists()
assert self.club.get_membership_for(user) is None
def test_access_unauthorized(self):
"""Test that users who never subscribed and anonymous users
cannot see the page.
"""
response = self.client.post(self.members_url)
assert response.status_code == 403
self.client.force_login(self.public)
response = self.client.post(self.members_url)
assert response.status_code == 403
def test_display(self):
"""Test that a GET request return a page where the requested
information are displayed.
"""
self.client.force_login(self.skia)
response = self.client.get(self.members_url)
assert response.status_code == 200
expected_html = (
"<table><thead><tr>"
"<td>Utilisateur</td><td>Rôle</td><td>Description</td>"
"<td>Depuis</td><td>Marquer comme ancien</td>"
"</tr></thead><tbody>"
)
memberships = self.club.members.ongoing().order_by("-role")
input_id = 0
for membership in memberships.select_related("user"):
user = membership.user
expected_html += (
f"<tr><td><a href=\"{reverse('core:user_profile', args=[user.id])}\">"
f"{user.get_display_name()}</a></td>"
f"<td>{settings.SITH_CLUB_ROLES[membership.role]}</td>"
f"<td>{membership.description}</td>"
f"<td>{membership.start_date}</td><td>"
)
if membership.role <= 3: # 3 is the role of skia
expected_html += (
'<input type="checkbox" name="users_old" '
f'value="{user.id}" '
f'id="id_users_old_{input_id}">'
)
input_id += 1
expected_html += "</td></tr>"
expected_html += "</tbody></table>"
self.assertInHTML(expected_html, response.content.decode())
def test_root_add_one_club_member(self):
"""Test that root users can add members to clubs, one at a time."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users": [self.subscriber.id], "role": 3},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
def test_root_add_multiple_club_member(self):
"""Test that root users can add multiple members at once to clubs."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{
"users": (self.subscriber.id, self.krophil.id),
"role": 3,
},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
self.assert_membership_started_today(self.krophil, role=3)
def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed
cannot be members of clubs.
"""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users": self.public.id, "role": 1},
)
assert not self.public.memberships.filter(club=self.club).exists()
assert '<ul class="errorlist"><li>' in response.content.decode()
response = self.client.post(
self.members_url,
{"users": self.old_subscriber.id, "role": 1},
)
assert not self.public.memberships.filter(club=self.club).exists()
assert self.club.get_membership_for(self.public) is None
assert '<ul class="errorlist"><li>' in response.content.decode()
def test_add_members_already_members(self):
"""Test that users who are already members of a club
cannot be added again to this club.
"""
self.client.force_login(self.root)
current_membership = self.skia.memberships.ongoing().get(club=self.club)
nb_memberships = self.skia.memberships.count()
self.client.post(
self.members_url,
{"users": self.skia.id, "role": current_membership.role + 1},
)
self.skia.refresh_from_db()
assert nb_memberships == self.skia.memberships.count()
new_membership = self.skia.memberships.ongoing().get(club=self.club)
assert current_membership == new_membership
assert self.club.get_membership_for(self.skia) == new_membership
def test_add_not_existing_users(self):
"""Test that not existing users cannot be added in clubs.
If one user in the request is invalid, no membership creation at all
can take place.
"""
self.client.force_login(self.root)
nb_memberships = self.club.members.count()
response = self.client.post(
self.members_url,
{"users": [9999], "role": 1},
)
assert response.status_code == 200
assert '<ul class="errorlist"><li>' in response.content.decode()
self.club.refresh_from_db()
assert self.club.members.count() == nb_memberships
response = self.client.post(
self.members_url,
{
"users": (self.subscriber.id, 9999),
"start_date": "12/06/2016",
"role": 3,
},
)
assert response.status_code == 200
assert '<ul class="errorlist"><li>' in response.content.decode()
self.club.refresh_from_db()
assert self.club.members.count() == nb_memberships
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=10).user
nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president)
response = self.client.post(
self.members_url,
{"users": self.subscriber.id, "role": 9},
)
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=9)
def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
self.client.force_login(self.skia)
nb_memberships = self.club.members.count()
response = self.client.post(
self.members_url,
{"users": self.subscriber.id, "role": 10},
)
assert response.status_code == 200
self.assertInHTML(
"<li>Vous n'avez pas la permission de faire cela</li>",
response.content.decode(),
)
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
assert not self.subscriber.memberships.filter(club=self.club).exists()
def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users": self.subscriber.id, "start_date": "12/06/2016"},
)
assert (
'<ul class="errorlist"><li>Vous devez choisir un r'
in response.content.decode()
)
def test_end_membership_self(self):
"""Test that a member can end its own membership."""
self.client.force_login(self.skia)
self.client.post(
self.members_url,
{"users_old": self.skia.id},
)
self.skia.refresh_from_db()
self.assert_membership_ended_today(self.skia)
def test_end_membership_lower_role(self):
"""Test that board members of the club can end memberships
of users with lower roles.
"""
# remainder : skia has role 3, comptable has role 10, richard has role 1
self.client.force_login(self.skia)
response = self.client.post(
self.members_url,
{"users_old": self.richard.id},
)
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
self.assert_membership_ended_today(self.richard)
def test_end_membership_higher_role(self):
"""Test that board members of the club cannot end memberships
of users with higher roles.
"""
membership = self.comptable.memberships.filter(club=self.club).first()
self.client.force_login(self.skia)
self.client.post(
self.members_url,
{"users_old": self.comptable.id},
)
self.club.refresh_from_db()
new_membership = self.club.get_membership_for(self.comptable)
assert new_membership is not None
assert new_membership == membership
membership = self.comptable.memberships.filter(club=self.club).first()
assert membership.end_date is None
def test_end_membership_as_main_club_board(self):
"""Test that board members of the main club can end the membership
of anyone.
"""
# make subscriber a board member
subscriber = subscriber_user.make()
Membership.objects.create(club=self.ae, user=subscriber, role=3)
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(subscriber)
response = self.client.post(
self.members_url,
{"users_old": self.comptable.id},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.comptable)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_root(self):
"""Test that root users can end the membership of anyone."""
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users_old": [self.comptable.id]},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.comptable)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_foreigner(self):
"""Test that users who are not in this club cannot end its memberships."""
nb_memberships = self.club.members.count()
membership = self.richard.memberships.filter(club=self.club).first()
self.client.force_login(self.subscriber)
self.client.post(
self.members_url,
{"users_old": [self.richard.id]},
)
# nothing should have changed
new_mem = self.club.get_membership_for(self.richard)
assert self.club.members.count() == nb_memberships
assert membership == new_mem
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)
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_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."""
anonymous = AnonymousUser()
assert not self.club.is_owned_by(anonymous)
assert not self.club.is_owned_by(self.subscriber)
# make sli a board member
self.sli.memberships.all().delete()
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."""
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
cls.rbatsbak = User.objects.get(username="rbatsbak")
cls.krophil = User.objects.get(username="krophil")
cls.comunity = User.objects.get(username="comunity")
cls.root = User.objects.get(username="root")
cls.bdf = Club.objects.get(unix_name=SITH_BAR_MANAGER["unix_name"])
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.bdf.id})
def setUp(self):
Membership(
user=self.rbatsbak,
club=self.bdf,
start_date=timezone.now(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
).save()
def test_mailing_list_add_no_moderation(self):
# Test with Communication admin
self.client.force_login(self.comunity)
response = self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
)
self.assertRedirects(response, self.mail_url)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion foyer@utbm.fr" in response.content.decode()
# Test with Root
self.client.force_login(self.root)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion mde@utbm.fr" in response.content.decode()
def test_mailing_list_add_moderation(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.content.decode()
assert "Liste de diffusion mde@utbm.fr" not in content
assert "<p>Listes de diffusions en attente de modération</p>" in content
assert "<li>mde@utbm.fr" in content
def test_mailing_list_forbidden(self):
# With anonymous user
response = self.client.get(self.mail_url)
self.assertContains(response, "", status_code=403)
# With user not in club
self.client.force_login(self.krophil)
response = self.client.get(self.mail_url)
assert response.status_code == 403
def test_add_new_subscription_fail_not_moderated(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" not in response.content.decode()
def test_add_new_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Add single user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" in response.content.decode()
# Add multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (self.comunity.id, self.rbatsbak.id),
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.content.decode()
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
# Add arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "arbitrary@git.an",
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.content.decode()
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
# Add user and arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "more.arbitrary@git.an",
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.content.decode()
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
assert "more.arbitrary@git.an" in content
assert "krophil@git.an" in content
def test_add_new_subscription_fail_form_errors(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Neither email or email is specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.content.decode(),
)
# No mailing specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
},
)
assert response.status_code == 200
assert _("This field is required") in response.content.decode()
# One of the selected users doesn't exist
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.content.decode(),
)
# An user has no email address
self.krophil.email = ""
self.krophil.save()
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("One of the selected users doesn't have an email address"),
response.content.decode(),
)
self.krophil.email = "krophil@git.an"
self.krophil.save()
# An user is added twice
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("This email is already suscribed in this mailing"),
response.content.decode(),
)
def test_remove_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
mde = Mailing.objects.get(email="mde")
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (
self.comunity.id,
self.rbatsbak.id,
self.krophil.id,
),
"subscription_mailing": mde.id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.content.decode()
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" in content
# Delete one user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.content.decode()
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" not in content
# Delete multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: [
user.id
for user in mde.subscriptions.filter(
user__in=[self.rbatsbak, self.comunity]
).all()
],
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.content.decode()
assert "comunity@git.an" not in content
assert "richard@git.an" not in content
assert "krophil@git.an" not in content
class TestClubSellingView(TestCase):
"""Perform basics tests to ensure that the page is available."""
@classmethod
def setUpTestData(cls):
cls.ae = Club.objects.get(unix_name="ae")
cls.skia = User.objects.get(username="skia")
def test_page_not_internal_error(self):
"""Test that the page does not return and internal error."""
self.client.force_login(self.skia)
response = self.client.get(
reverse("club:club_sellings", kwargs={"club_id": self.ae.id})
)
assert response.status_code == 200

60
club/tests/base.py Normal file
View File

@ -0,0 +1,60 @@
from datetime import timedelta
from django.conf import settings
from django.core.cache import cache
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
class TestClub(TestCase):
"""Set up data for test cases related to clubs and membership.
The generated dataset is the one created by the populate command,
plus the following modifications :
- `self.club` is a dummy club
- `self.club` has two board members :
simple_board_member (role 3) and president (role 10)
- `self.club` has one regular member : richard
- `self.club` has one former member : sli (who had role 2)
- None of the `self.club` members are in the AE club.
"""
@classmethod
def setUpTestData(cls):
# subscribed users - initial members
cls.president, cls.simple_board_member = subscriber_user.make(_quantity=2)
cls.richard = User.objects.get(username="rbatsbak")
cls.sli = User.objects.get(username="sli")
cls.root = baker.make(User, is_superuser=True)
cls.old_subscriber = old_subscriber_user.make()
cls.public = baker.make(User)
# subscribed users - not initial member
cls.krophil = User.objects.get(username="krophil")
cls.subscriber = subscriber_user.make()
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club)
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
a_month_ago = now() - timedelta(days=30)
yesterday = now() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=cls.club)
membership_recipe.make(
user=cls.simple_board_member, start_date=a_month_ago, role=3
)
membership_recipe.make(user=cls.richard, role=1)
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
membership_recipe.make( # sli was a member but isn't anymore
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
)
def setUp(self):
cache.clear()

38
club/tests/test_edit.py Normal file
View File

@ -0,0 +1,38 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
def test_club_board_member_cannot_edit_club_properties(client: Client):
user = subscriber_user.make()
club = baker.make(Club, name="old name", is_active=True, address="old address")
baker.make(Membership, club=club, user=user, role=7)
client.force_login(user)
res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}),
{"name": "new name", "is_active": False, "address": "new address"},
)
# The request should success,
# but admin-only fields shouldn't be taken into account
assertRedirects(res, club.get_absolute_url())
club.refresh_from_db()
assert club.name == "old name"
assert club.is_active
assert club.address == "new address"
@pytest.mark.django_db
def test_edit_club_page_doesnt_crash(client: Client):
"""crash test for club:club_edit"""
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, club=club, user=user, role=3)
client.force_login(user)
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
assert res.status_code == 200

327
club/tests/test_mailing.py Normal file
View File

@ -0,0 +1,327 @@
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from club.forms import MailingForm
from club.models import Club, Mailing, Membership
from core.models import User
class TestMailingForm(TestCase):
"""Perform validation tests for MailingForm."""
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
cls.rbatsbak = User.objects.get(username="rbatsbak")
cls.krophil = User.objects.get(username="krophil")
cls.comunity = User.objects.get(username="comunity")
cls.root = User.objects.get(username="root")
cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id})
Membership(
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
).save()
def test_mailing_list_add_no_moderation(self):
# Test with Communication admin
self.client.force_login(self.comunity)
response = self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
)
self.assertRedirects(response, self.mail_url)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion foyer@utbm.fr" in response.text
# Test with Root
self.client.force_login(self.root)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion mde@utbm.fr" in response.text
def test_mailing_list_add_moderation(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "Liste de diffusion mde@utbm.fr" not in content
assert "<p>Listes de diffusions en attente de modération</p>" in content
assert "<li>mde@utbm.fr" in content
def test_mailing_list_forbidden(self):
# With anonymous user
response = self.client.get(self.mail_url)
self.assertContains(response, "", status_code=403)
# With user not in club
self.client.force_login(self.krophil)
response = self.client.get(self.mail_url)
assert response.status_code == 403
def test_add_new_subscription_fail_not_moderated(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" not in response.text
def test_add_new_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Add single user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" in response.text
# Add multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (self.comunity.id, self.rbatsbak.id),
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
# Add arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "arbitrary@git.an",
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
# Add user and arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "more.arbitrary@git.an",
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
assert "more.arbitrary@git.an" in content
assert "krophil@git.an" in content
def test_add_new_subscription_fail_form_errors(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Neither email or email is specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# No mailing specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
},
)
assert response.status_code == 200
assert _("This field is required") in response.text
# One of the selected users doesn't exist
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# An user has no email address
self.krophil.email = ""
self.krophil.save()
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("One of the selected users doesn't have an email address"),
response.text,
)
self.krophil.email = "krophil@git.an"
self.krophil.save()
# An user is added twice
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("This email is already suscribed in this mailing"),
response.text,
)
def test_remove_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
mde = Mailing.objects.get(email="mde")
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (
self.comunity.id,
self.rbatsbak.id,
self.krophil.id,
),
"subscription_mailing": mde.id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" in content
# Delete one user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" not in content
# Delete multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: [
user.id
for user in mde.subscriptions.filter(
user__in=[self.rbatsbak, self.comunity]
).all()
],
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" not in content
assert "richard@git.an" not in content
assert "krophil@git.an" not in content

View File

@ -0,0 +1,492 @@
from bs4 import BeautifulSoup
from django.conf import settings
from django.core.cache import cache
from django.db.models import Max
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker
from club.forms import ClubMemberForm
from club.models import Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
class TestMembershipQuerySet(TestClub):
def test_ongoing(self):
"""Test that the ongoing queryset method returns the memberships that
are not ended.
"""
current_members = list(self.club.members.ongoing().order_by("id"))
expected = [
self.simple_board_member.memberships.get(club=self.club),
self.president.memberships.get(club=self.club),
self.richard.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert current_members == expected
def test_ongoing_with_membership_ending_today(self):
"""Test that a membership ending the present day is considered as ended."""
today = localdate()
self.richard.memberships.filter(club=self.club).update(end_date=today)
current_members = list(self.club.members.ongoing().order_by("id"))
expected = [
self.simple_board_member.memberships.get(club=self.club),
self.president.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert current_members == expected
def test_board(self):
"""Test that the board queryset method returns the memberships
of user in the club board.
"""
board_members = list(self.club.members.board().order_by("id"))
expected = [
self.simple_board_member.memberships.get(club=self.club),
self.president.memberships.get(club=self.club),
# sli is no more member, but he was in the board
self.sli.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert board_members == expected
def test_ongoing_board(self):
"""Test that combining ongoing and board returns users
who are currently board members of the club.
"""
members = list(self.club.members.ongoing().board().order_by("id"))
expected = [
self.simple_board_member.memberships.get(club=self.club),
self.president.memberships.get(club=self.club),
]
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
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.simple_board_member.memberships.get(club=self.club)
mem_comptable = self.president.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
cache.set(
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
)
# should delete the subscriptions of simple_board_member and president
self.club.members.ongoing().board().delete()
for membership in (mem_skia, mem_comptable):
cached_mem = cache.get(
f"membership_{membership.club_id}_{membership.user_id}"
)
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()).issuperset(club_groups)
user.memberships.all().delete()
assert set(user.groups.all()).isdisjoint(club_groups)
class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: int):
"""Assert that the given membership is active and started today."""
membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None
assert localtime(now()).date() == membership.start_date
assert membership.end_date is None
assert membership.role == role
assert membership.club.get_membership_for(user) == membership
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."""
today = localtime(now()).date()
assert user.memberships.filter(club=self.club, end_date=today).exists()
assert self.club.get_membership_for(user) is None
def test_access_unauthorized(self):
"""Test that users who never subscribed and anonymous users
cannot see the page.
"""
response = self.client.post(self.members_url)
assert response.status_code == 403
self.client.force_login(self.public)
response = self.client.post(self.members_url)
assert response.status_code == 403
def test_display(self):
"""Test that a GET request return a page where the requested
information are displayed.
"""
self.client.force_login(self.simple_board_member)
response = self.client.get(self.members_url)
assert response.status_code == 200
soup = BeautifulSoup(response.text, "lxml")
table = soup.find("table", id="club_members_table")
assert [r.text for r in table.find("thead").find_all("td")] == [
"Utilisateur",
"Rôle",
"Description",
"Depuis",
"Marquer comme ancien",
]
rows = table.find("tbody").find_all("tr")
memberships = self.club.members.ongoing().order_by("-role")
for row, membership in zip(
rows, memberships.select_related("user"), strict=False
):
user = membership.user
user_url = reverse("core:user_profile", args=[user.id])
cols = row.find_all("td")
user_link = cols[0].find("a")
assert user_link.attrs["href"] == user_url
assert user_link.text == user.get_display_name()
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date)
if membership.role <= 3: # 3 is the role of simple_board_member
form_input = cols[4].find("input")
expected_attrs = {
"type": "checkbox",
"name": "users_old",
"value": str(user.id),
}
assert form_input.attrs.items() >= expected_attrs.items()
else:
assert cols[4].find_all() == []
def test_root_add_one_club_member(self):
"""Test that root users can add members to clubs, one at a time."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users": [self.subscriber.id], "role": 3},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
def test_root_add_multiple_club_member(self):
"""Test that root users can add multiple members at once to clubs."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{
"users": (self.subscriber.id, self.krophil.id),
"role": 3,
},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
self.assert_membership_started_today(self.krophil, role=3)
def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed
cannot be members of clubs.
"""
for user in self.public, self.old_subscriber:
form = ClubMemberForm(
data={"users": [user.id], "role": 1},
request_user=self.root,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"users": [
"L'utilisateur doit être cotisant pour faire partie d'un club"
]
}
def test_add_members_already_members(self):
"""Test that users who are already members of a club
cannot be added again to this club.
"""
self.client.force_login(self.root)
current_membership = self.simple_board_member.memberships.ongoing().get(
club=self.club
)
nb_memberships = self.simple_board_member.memberships.count()
self.client.post(
self.members_url,
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
)
self.simple_board_member.refresh_from_db()
assert nb_memberships == self.simple_board_member.memberships.count()
new_membership = self.simple_board_member.memberships.ongoing().get(
club=self.club
)
assert current_membership == new_membership
assert self.club.get_membership_for(self.simple_board_member) == new_membership
def test_add_not_existing_users(self):
"""Test that not existing users cannot be added in clubs.
If one user in the request is invalid, no membership creation at all
can take place.
"""
nb_memberships = self.club.members.count()
max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubMemberForm(
data={"users": members, "role": 1},
request_user=self.root,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"users": [
"Sélectionnez un choix valide. "
f"{max_id + 1} n\u2019en fait pas partie."
]
}
self.club.refresh_from_db()
assert self.club.members.count() == nb_memberships
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=10).user
nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president)
response = self.client.post(
self.members_url,
{"users": self.subscriber.id, "role": 9},
)
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=9)
def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
form = ClubMemberForm(
data={"users": [self.subscriber.id], "role": 10},
request_user=self.simple_board_member,
club=self.club,
)
nb_memberships = self.club.members.count()
assert not form.is_valid()
assert form.errors == {
"__all__": ["Vous n'avez pas la permission de faire cela"]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
assert not self.subscriber.memberships.filter(club=self.club).exists()
def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails."""
self.client.force_login(self.root)
form = ClubMemberForm(
data={"users": [self.subscriber.id]},
request_user=self.simple_board_member,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {"role": ["Vous devez choisir un rôle"]}
def test_end_membership_self(self):
"""Test that a member can end its own membership."""
self.client.force_login(self.simple_board_member)
self.client.post(
self.members_url,
{"users_old": self.simple_board_member.id},
)
self.simple_board_member.refresh_from_db()
self.assert_membership_ended_today(self.simple_board_member)
def test_end_membership_lower_role(self):
"""Test that board members of the club can end memberships
of users with lower roles.
"""
# remainder : simple_board_member has role 3, president has role 10, richard has role 1
self.client.force_login(self.simple_board_member)
response = self.client.post(
self.members_url,
{"users_old": self.richard.id},
)
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
self.assert_membership_ended_today(self.richard)
def test_end_membership_higher_role(self):
"""Test that board members of the club cannot end memberships
of users with higher roles.
"""
membership = self.president.memberships.filter(club=self.club).first()
self.client.force_login(self.simple_board_member)
self.client.post(
self.members_url,
{"users_old": self.president.id},
)
self.club.refresh_from_db()
new_membership = self.club.get_membership_for(self.president)
assert new_membership is not None
assert new_membership == membership
membership = self.president.memberships.filter(club=self.club).first()
assert membership.end_date is None
def test_end_membership_as_main_club_board(self):
"""Test that board members of the main club can end the membership
of anyone.
"""
# make subscriber a board member
subscriber = subscriber_user.make()
Membership.objects.create(club=self.ae, user=subscriber, role=3)
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(subscriber)
response = self.client.post(
self.members_url,
{"users_old": self.president.id},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.president)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_root(self):
"""Test that root users can end the membership of anyone."""
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users_old": [self.president.id]},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.president)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_foreigner(self):
"""Test that users who are not in this club cannot end its memberships."""
nb_memberships = self.club.members.count()
membership = self.richard.memberships.filter(club=self.club).first()
self.client.force_login(self.subscriber)
self.client.post(
self.members_url,
{"users_old": [self.richard.id]},
)
# nothing should have changed
new_mem = self.club.get_membership_for(self.richard)
assert self.club.members.count() == nb_memberships
assert membership == new_mem
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)
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_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."""
anonymous = AnonymousUser()
assert not self.club.is_owned_by(anonymous)
assert not self.club.is_owned_by(self.subscriber)
# make sli a board member
self.sli.memberships.all().delete()
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

39
club/tests/test_page.py Normal file
View File

@ -0,0 +1,39 @@
import pytest
from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual
from club.models import Club
from core.markdown import markdown
from core.models import PageRev, User
@pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client):
"""Test the club Page is properly displayed on the club main view"""
club = baker.make(Club)
content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=1, content=content)
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail").find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
@pytest.mark.django_db
def test_club_main_page_without_content(client: Client):
"""Test the club view works, even if the club page is empty"""
club = baker.make(Club)
club.page.revisions.all().delete()
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail")
assert detail_html.find_all("markdown") == []

38
club/tests/test_sales.py Normal file
View File

@ -0,0 +1,38 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from club.forms import SellingsForm
from club.models import Club
from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer
@pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client):
club = baker.make(Club)
admin = baker.make(User, is_superuser=True)
client.force_login(admin)
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
assert response.status_code == 200
@pytest.mark.django_db
def test_sales_form_counter_filter():
"""Test that counters are properly filtered in SellingsForm"""
club = baker.make(Club)
counters = baker.make(
Counter, _quantity=5, _bulk_create=True, name=iter(["Z", "a", "B", "e", "f"])
)
counters[0].club = club
counters[0].save()
sale_recipe.make(
counter=counters[1], club=club, unit_price=0, customer=baker.make(Customer)
)
product_recipe.make(counters=[counters[2]], club=club)
form = SellingsForm(club)
form_counters = list(form.fields["counters"].queryset)
assert form_counters == [counters[1], counters[2], counters[0]]

View File

@ -26,7 +26,6 @@ from django.urls import path
from club.views import (
ClubCreateView,
ClubEditPropView,
ClubEditView,
ClubListView,
ClubMailingView,
@ -37,7 +36,6 @@ from club.views import (
ClubRevView,
ClubSellingCSVView,
ClubSellingView,
ClubStatView,
ClubToolsView,
ClubView,
MailingAutoGenerationView,
@ -54,7 +52,6 @@ from club.views import (
urlpatterns = [
path("", ClubListView.as_view(), name="club_list"),
path("new/", ClubCreateView.as_view(), name="club_new"),
path("stats/", ClubStatView.as_view(), name="club_stats"),
path("<int:club_id>/", ClubView.as_view(), name="club_view"),
path(
"<int:club_id>/rev/<int:rev_id>/", ClubRevView.as_view(), name="club_view_rev"
@ -72,7 +69,6 @@ urlpatterns = [
path(
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
),
path("<int:club_id>/prop/", ClubEditPropView.as_view(), name="club_prop"),
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
path("<int:club_id>/mailing/", ClubMailingView.as_view(), name="mailing"),
path(

View File

@ -37,12 +37,19 @@ from django.http import (
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext as _t
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic import DetailView, ListView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import ClubEditForm, ClubMemberForm, MailingForm, SellingsForm
from club.forms import (
ClubAdminEditForm,
ClubEditForm,
ClubMemberForm,
MailingForm,
SellingsForm,
)
from club.models import Club, Mailing, MailingSubscription, Membership
from com.views import (
PosterCreateBaseView,
@ -50,12 +57,7 @@ from com.views import (
PosterEditBaseView,
PosterListBaseView,
)
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin
@ -78,23 +80,23 @@ class ClubTabsMixin(TabedViewMixin):
}
]
if self.request.user.can_view(self.object):
tab_list.append(
{
"url": reverse(
"club:club_members", kwargs={"club_id": self.object.id}
),
"slug": "members",
"name": _("Members"),
}
)
tab_list.append(
{
"url": reverse(
"club:club_old_members", kwargs={"club_id": self.object.id}
),
"slug": "elderlies",
"name": _("Old members"),
}
tab_list.extend(
[
{
"url": reverse(
"club:club_members", kwargs={"club_id": self.object.id}
),
"slug": "members",
"name": _("Members"),
},
{
"url": reverse(
"club:club_old_members", kwargs={"club_id": self.object.id}
),
"slug": "elderlies",
"name": _("Old members"),
},
]
)
if self.object.page:
tab_list.append(
@ -107,21 +109,23 @@ class ClubTabsMixin(TabedViewMixin):
}
)
if self.request.user.can_edit(self.object):
tab_list.append(
{
"url": reverse("club:tools", kwargs={"club_id": self.object.id}),
"slug": "tools",
"name": _("Tools"),
}
)
tab_list.append(
{
"url": reverse(
"club:club_edit", kwargs={"club_id": self.object.id}
),
"slug": "edit",
"name": _("Edit"),
}
tab_list.extend(
[
{
"url": reverse(
"club:tools", kwargs={"club_id": self.object.id}
),
"slug": "tools",
"name": _("Tools"),
},
{
"url": reverse(
"club:club_edit", kwargs={"club_id": self.object.id}
),
"slug": "edit",
"name": _("Edit"),
},
]
)
if self.object.page and self.request.user.can_edit(self.object.page):
tab_list.append(
@ -134,40 +138,30 @@ class ClubTabsMixin(TabedViewMixin):
"name": _("Edit club page"),
}
)
tab_list.append(
{
"url": reverse(
"club:club_sellings", kwargs={"club_id": self.object.id}
),
"slug": "sellings",
"name": _("Sellings"),
}
)
tab_list.append(
{
"url": reverse("club:mailing", kwargs={"club_id": self.object.id}),
"slug": "mailing",
"name": _("Mailing list"),
}
)
tab_list.append(
{
"url": reverse(
"club:poster_list", kwargs={"club_id": self.object.id}
),
"slug": "posters",
"name": _("Posters list"),
}
)
if self.request.user.is_owner(self.object):
tab_list.append(
{
"url": reverse(
"club:club_prop", kwargs={"club_id": self.object.id}
),
"slug": "props",
"name": _("Props"),
}
tab_list.extend(
[
{
"url": reverse(
"club:club_sellings", kwargs={"club_id": self.object.id}
),
"slug": "sellings",
"name": _("Sellings"),
},
{
"url": reverse(
"club:mailing", kwargs={"club_id": self.object.id}
),
"slug": "mailing",
"name": _("Mailing list"),
},
{
"url": reverse(
"club:poster_list", kwargs={"club_id": self.object.id}
),
"slug": "posters",
"name": _("Posters list"),
},
]
)
return tab_list
@ -189,8 +183,12 @@ class ClubView(ClubTabsMixin, DetailView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
if self.object.page and self.object.page.revisions.exists():
kwargs["page_revision"] = self.object.page.revisions.last().content
kwargs["page_revision"] = (
PageRev.objects.filter(page_id=self.object.page_id)
.order_by("-date")
.values_list("content", flat=True)
.first()
)
return kwargs
@ -253,6 +251,10 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
template_name = "club/club_members.jinja"
current_tab = "members"
@cached_property
def members(self) -> list[Membership]:
return list(self.object.members.ongoing().order_by("-role"))
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["request_user"] = self.request.user
@ -260,8 +262,8 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
kwargs["club_members"] = self.members
return kwargs
def get_context_data(self, *args, **kwargs):
kwargs = super().get_context_data(*args, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["members"] = self.members
return kwargs
@ -280,12 +282,8 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
membership.save()
return resp
def dispatch(self, request, *args, **kwargs):
self.members = self.get_object().members.ongoing().order_by("-role")
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse_lazy("club:club_members", kwargs={"club_id": self.object.id})
return self.request.path
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
@ -452,23 +450,23 @@ class ClubSellingCSVView(ClubSellingView):
class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView):
"""Edit a Club's main informations (for the club's members)."""
"""Edit a Club.
Regular club board members will be able to edit the main infos
(like the logo and the description).
Admins will also be able to edit the club properties
(like the name and the parent club).
"""
model = Club
pk_url_kwarg = "club_id"
form_class = ClubEditForm
template_name = "core/edit.jinja"
template_name = "club/edit_club.jinja"
current_tab = "edit"
class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
"""Edit the properties of a Club object (for the Sith admins)."""
model = Club
pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent", "is_active"]
template_name = "core/edit.jinja"
current_tab = "props"
def get_form_class(self):
if self.object.is_owned_by(self.request.user):
return ClubAdminEditForm
return ClubEditForm
class ClubCreateView(PermissionRequiredMixin, CreateView):
@ -476,8 +474,8 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
model = Club
pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent"]
template_name = "core/edit.jinja"
fields = ["name", "parent"]
template_name = "core/create.jinja"
permission_required = "club.add_club"
@ -522,15 +520,6 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})
class ClubStatView(TemplateView):
template_name = "club/stats.jinja"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["club_list"] = Club.objects.all()
return kwargs
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""A list of mailing for a given club."""
@ -542,26 +531,19 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["club_id"] = self.get_object().id
kwargs["club_id"] = self.object.id
kwargs["user_id"] = self.request.user.id
kwargs["mailings"] = self.mailings
kwargs["mailings"] = self.object.mailings.all()
return kwargs
def dispatch(self, request, *args, **kwargs):
self.mailings = Mailing.objects.filter(club_id=self.get_object().id).all()
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["club"] = self.get_object()
mailings = list(self.object.mailings.all())
kwargs["club"] = self.object
kwargs["user"] = self.request.user
kwargs["mailings"] = self.mailings
kwargs["mailings_moderated"] = (
kwargs["mailings"].exclude(is_moderated=False).all()
)
kwargs["mailings_not_moderated"] = (
kwargs["mailings"].exclude(is_moderated=True).all()
)
kwargs["mailings"] = mailings
kwargs["mailings_moderated"] = [m for m in mailings if m.is_moderated]
kwargs["mailings_not_moderated"] = [m for m in mailings if not m.is_moderated]
kwargs["form_actions"] = {
"NEW_MALING": self.form_class.ACTION_NEW_MAILING,
"NEW_SUBSCRIPTION": self.form_class.ACTION_NEW_SUBSCRIPTION,
@ -572,7 +554,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
def add_new_mailing(self, cleaned_data) -> ValidationError | None:
"""Create a new mailing list from the form."""
mailing = Mailing(
club=self.get_object(),
club=self.object,
email=cleaned_data["mailing_email"],
moderator=self.request.user,
is_moderated=False,
@ -649,7 +631,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
return resp
def get_success_url(self, **kwargs):
return reverse_lazy("club:mailing", kwargs={"club_id": self.get_object().id})
return reverse("club:mailing", kwargs={"club_id": self.object.id})
class MailingDeleteView(CanEditMixin, DeleteView):

View File

@ -2,7 +2,10 @@ from pydantic import TypeAdapter
from club.models import Club
from club.schemas import ClubSchema
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultiple,
)
_js = ["bundled/club/components/ajax-select-index.ts"]

View File

@ -1,15 +1,14 @@
from pathlib import Path
from typing import Literal
from django.conf import settings
from django.http import Http404, HttpResponse
from django.http import HttpResponse
from django.utils.cache import add_never_cache_headers
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 com.calendar import IcsCalendar
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate
from com.schemas import NewsDateFilterSchema, NewsDateSchema
from core.auth.api_permissions import HasPerm
@ -18,26 +17,11 @@ 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 its 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())
response = send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response)
return response
@route.get(
"/unpublished.ics",
@ -45,10 +29,12 @@ class CalendarController(ControllerBase):
url_name="calendar_unpublished",
)
def calendar_unpublished(self):
return HttpResponse(
response = HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
content_type="text/calendar",
)
add_never_cache_headers(response)
return response
@api_controller("/news")

View File

@ -8,7 +8,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from club.widgets.ajax_select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester

View File

@ -1,11 +1,11 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import final
import requests
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain
from django.db.models import F, QuerySet
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
@ -16,38 +16,18 @@ from com.models import NewsDate
from core.models import User
@final
def as_absolute_url(url: str, request: HttpRequest | None = None) -> str:
return add_domain(
Site.objects.get_current(request=request),
url,
secure=request.is_secure() if request is not None else settings.HTTPS,
)
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 = requests.get(
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics"
)
if not calendar.ok:
return None
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._EXTERNAL_CALENDAR, "wb") as f:
_ = f.write(calendar.content)
return cls._EXTERNAL_CALENDAR
@classmethod
def get_internal(cls) -> Path:
if not cls._INTERNAL_CALENDAR.exists():
@ -87,7 +67,9 @@ class IcsCalendar:
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}),
url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news_date.news.id})
),
)
calendar.events.append(event)

View File

@ -160,9 +160,12 @@ class News(models.Model):
)
def news_notification_callback(notif):
def news_notification_callback(notif: Notification):
# the NewsDate linked to the News
# which creation triggered this callback may not exist yet,
# so it's important to filter by "not past date" rather than by "future date"
count = News.objects.filter(
dates__start_date__gt=timezone.now(), is_published=False
~Q(dates__start_date__gt=timezone.now()), is_published=False
).count()
if count:
notif.viewed = False

View File

@ -2,7 +2,7 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.controllers import RouteContext
from ninja_extra.context import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema

View File

@ -1,7 +1,7 @@
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from com.calendar import IcsCalendar
from com.ics_calendar import IcsCalendar
from com.models import News

View File

@ -8,7 +8,6 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import {
calendarCalendarExternal,
calendarCalendarInternal,
calendarCalendarUnpublished,
newsDeleteNews,
@ -18,11 +17,12 @@ import {
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete"];
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
private helpUrl = "";
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") {
@ -34,6 +34,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
}
if (name === "ics-help-url") {
this.helpUrl = newValue;
}
}
isMobile() {
@ -45,7 +49,18 @@ export class IcsCalendar extends inheritHtmlElement("div") {
return this.isMobile() ? "listMonth" : "dayGridMonth";
}
currentToolbar() {
currentFooterToolbar() {
if (this.isMobile()) {
return {
start: "",
center: "getCalendarLink helpButton",
end: "",
};
}
return { start: "getCalendarLink helpButton", center: "", end: "" };
}
currentHeaderToolbar() {
if (this.isMobile()) {
return {
left: "prev,next",
@ -77,15 +92,8 @@ export class IcsCalendar extends inheritHtmlElement("div") {
);
}
async refreshEvents() {
refreshEvents() {
this.click(); // Remove focus from popup
// We can't just refresh events because some ics files are in
// local browser cache (especially internal.ics)
// To invalidate the cache, we need to remove the source and add it again
this.calendar.removeAllEventSources();
for (const source of await this.getEventSources()) {
this.calendar.addEventSource(source);
}
this.calendar.refetchEvents();
}
@ -104,7 +112,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
},
}),
);
await this.refreshEvents();
this.refreshEvents();
}
async unpublishNews(id: number) {
@ -122,7 +130,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
},
}),
);
await this.refreshEvents();
this.refreshEvents();
}
async deleteNews(id: number) {
@ -140,27 +148,23 @@ export class IcsCalendar extends inheritHtmlElement("div") {
},
}),
);
await this.refreshEvents();
this.refreshEvents();
}
async getEventSources() {
const cacheInvalidate = `?invalidate=${Date.now()}`;
return [
{
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
url: `${await makeUrl(calendarCalendarInternal)}`,
format: "ics",
className: "internal",
cache: false,
},
{
url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
format: "ics",
className: "external",
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
url: `${await makeUrl(calendarCalendarUnpublished)}`,
format: "ics",
color: "red",
className: "unpublished",
cache: false,
},
];
}
@ -224,9 +228,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
};
const makePopupTools = (event: EventImpl) => {
if (event.source.internalEventSource.ui.classNames.includes("external")) {
return null;
}
if (!(this.canDelete || this.canModerate)) {
return null;
}
@ -312,14 +313,55 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.calendar = new Calendar(this.node, {
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
locales: [frLocale, enLocale],
customButtons: {
getCalendarLink: {
text: gettext("Copy calendar link"),
click: async (event: Event) => {
const button = event.target as HTMLButtonElement;
button.classList.add("text-copy");
button.setAttribute("tooltip-class", "calendar-copy-tooltip");
if (!button.hasAttribute("tooltip-position")) {
button.setAttribute("tooltip-position", "top");
}
if (button.classList.contains("text-copied")) {
button.classList.remove("text-copied");
}
button.setAttribute("tooltip", gettext("Link copied"));
navigator.clipboard.writeText(
new URL(
await makeUrl(calendarCalendarInternal),
window.location.origin,
).toString(),
);
setTimeout(() => {
button.setAttribute("tooltip-class", "calendar-copy-tooltip text-copied");
button.classList.remove("text-copied");
button.classList.add("text-copied");
button.classList.remove("text-copy");
}, 1500);
},
},
helpButton: {
text: "?",
hint: gettext("How to use calendar link"),
click: () => {
if (this.helpUrl) {
window.open(this.helpUrl, "_blank");
}
},
},
},
height: "auto",
locale: this.locale,
initialView: this.currentView(),
headerToolbar: this.currentToolbar(),
headerToolbar: this.currentHeaderToolbar(),
footerToolbar: this.currentFooterToolbar(),
eventSources: await this.getEventSources(),
lazyFetching: false,
windowResize: () => {
this.calendar.changeView(this.currentView());
this.calendar.setOption("headerToolbar", this.currentToolbar());
this.calendar.setOption("headerToolbar", this.currentHeaderToolbar());
this.calendar.setOption("footerToolbar", this.currentFooterToolbar());
},
eventClick: (event) => {
// Avoid our popup to be deleted because we clicked outside of it

View File

@ -1,4 +1,5 @@
@import "core/static/core/colors";
@import "core/static/core/tooltips";
:root {
@ -98,4 +99,51 @@ ics-calendar {
background: white;
}
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
.fc .fc-getCalendarLink-button {
margin-right: 0.5rem;
}
.fc .fc-helpButton-button {
border-radius: 70%;
padding-left: 0.5rem;
padding-right: 0.5rem;
background-color: rgba(0, 0, 0, 0.8);
transition: 100ms ease-out;
width: 30px;
height: 30px;
font-size: 11px;
}
.fc .fc-helpButton-button:hover {
background-color: rgba(20, 20, 20, 0.6);
}
}
.tooltip.calendar-copy-tooltip {
opacity: 1;
transition: opacity 500ms ease-in;
}
.tooltip.calendar-copy-tooltip.text-copied {
opacity: 0;
transition: opacity 500ms ease-out;
}

View File

@ -56,9 +56,11 @@
#upcoming-events {
max-height: 600px;
overflow-y: scroll;
overflow-x: clip;
#load-more-news-button {
text-align: center;
button {
width: 150px;
}
@ -194,6 +196,7 @@
img {
height: 75px;
}
.header_content {
display: flex;
flex-direction: column;

View File

@ -192,6 +192,7 @@
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
@calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
ics-help-url="{{ url('core:page', page_name='Index/calendrier')}}"
locale="{{ get_language() }}"
can_moderate="{{ user.has_perm("com.moderate_news") }}"
can_delete="{{ user.has_perm("com.delete_news") }}"

View File

@ -1,9 +1,6 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
from pathlib import Path
from typing import Callable
from unittest.mock import MagicMock, patch
from urllib.parse import quote
import pytest
from django.conf import settings
@ -11,12 +8,11 @@ from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import now
from model_bakery import baker, seq
from pytest_django.asserts import assertNumQueries
from com.calendar import IcsCalendar
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User
@ -41,78 +37,6 @@ def accel_redirect_to_file(response: HttpResponse) -> Path | None:
)
@pytest.mark.django_db
class TestExternalCalendar:
@pytest.fixture
def mock_request(self):
mock = MagicMock()
with patch("requests.get", 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)
def test_fetch_error(self, client: Client, mock_request: MagicMock):
mock_request.return_value = MockResponse(ok=False, value="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(ok=True, value="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)
@ -241,9 +165,9 @@ class TestFetchNewsDates(TestCase):
assert dates[1]["news"]["summary"] == markdown(summary_2)
def test_fetch(self):
after = quote((now() + timedelta(days=1)).isoformat())
after = (now() + timedelta(days=1)).isoformat()
response = self.client.get(
reverse("api:fetch_news_dates") + f"?page_size=3&after={after}"
reverse("api:fetch_news_dates", query={"page_size": 3, "after": after})
)
assert response.status_code == 200
dates = response.json()["results"]

View File

@ -0,0 +1,23 @@
import pytest
from django.conf import settings
from model_bakery import baker
from com.models import News
from core.models import Group, Notification, User
@pytest.mark.django_db
def test_notification_created():
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admin_group.users.all().delete()
Notification.objects.all().delete()
com_admin = baker.make(User, groups=[com_admin_group])
for i in range(2):
# news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one.
baker.make(News)
notifications = list(Notification.objects.all())
assert len(notifications) == 1
assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION"
assert notifications[0].param == str(i + 1)

View File

@ -19,7 +19,7 @@ import pytest
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import html
from django.utils.timezone import localtime, now
@ -305,7 +305,7 @@ class TestNewsCreation(TestCase):
# we will just test that the ICS is modified.
# Checking that the ICS is *well* modified is up to the ICS tests
with patch("com.calendar.IcsCalendar.make_internal") as mocked:
with patch("com.ics_calendar.IcsCalendar.make_internal") as mocked:
self.client.post(reverse("com:news_new"), self.valid_payload)
mocked.assert_called()
@ -314,7 +314,7 @@ class TestNewsCreation(TestCase):
self.valid_payload["occurrences"] = 2
last_news = News.objects.order_by("id").last()
with patch("com.calendar.IcsCalendar.make_internal") as mocked:
with patch("com.ics_calendar.IcsCalendar.make_internal") as mocked:
self.client.post(
reverse("com:news_edit", kwargs={"news_id": last_news.id}),
self.valid_payload,
@ -323,7 +323,7 @@ class TestNewsCreation(TestCase):
@pytest.mark.django_db
def test_feed(client):
def test_feed(client: Client):
"""Smoke test that checks that the atom feed is working"""
Site.objects.clear_cache()
with assertNumQueries(2):
@ -332,3 +332,22 @@ def test_feed(client):
resp = client.get(reverse("com:news_feed"))
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/rss+xml; charset=utf-8"
@pytest.mark.django_db
@pytest.mark.parametrize(
"url",
[
reverse("com:poster_list"),
reverse("com:poster_create"),
reverse("com:poster_moderate_list"),
],
)
def test_poster_management_views_crash_test(client: Client, url: str):
"""Test that poster management views work"""
user = baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
)
client.force_login(user)
res = client.get(url)
assert res.status_code == 200

View File

@ -43,8 +43,8 @@ from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing
from com.calendar import IcsCalendar
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.auth.mixins import (
CanEditPropMixin,
@ -61,8 +61,7 @@ sith = Sith.objects.first
class ComTabsMixin(TabedViewMixin):
def get_tabs_title(self):
return _("Communication administration")
tabs_title = _("Communication administration")
def get_list_of_tabs(self):
return [
@ -559,7 +558,11 @@ class MailingModerateView(View):
raise PermissionDenied
class PosterListBaseView(ListView):
class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin):
current_tab = "posters"
class PosterListBaseView(PosterAdminViewMixin, ListView):
"""List communication posters."""
current_tab = "posters"
@ -586,7 +589,7 @@ class PosterListBaseView(ListView):
return kwargs
class PosterCreateBaseView(CreateView):
class PosterCreateBaseView(PosterAdminViewMixin, CreateView):
"""Create communication poster."""
current_tab = "posters"
@ -618,7 +621,7 @@ class PosterCreateBaseView(CreateView):
return super().form_valid(form)
class PosterEditBaseView(UpdateView):
class PosterEditBaseView(PosterAdminViewMixin, UpdateView):
"""Edit communication poster."""
pk_url_kwarg = "poster_id"
@ -664,7 +667,7 @@ class PosterEditBaseView(UpdateView):
return super().form_valid(form)
class PosterDeleteBaseView(DeleteView):
class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView):
"""Edit communication poster."""
pk_url_kwarg = "poster_id"
@ -681,7 +684,7 @@ class PosterDeleteBaseView(DeleteView):
return super().dispatch(request, *args, **kwargs)
class PosterListView(IsComAdminMixin, ComTabsMixin, PosterListBaseView):
class PosterListView(PosterListBaseView):
"""List communication posters."""
def get_context_data(self, **kwargs):
@ -690,7 +693,7 @@ class PosterListView(IsComAdminMixin, ComTabsMixin, PosterListBaseView):
return kwargs
class PosterCreateView(IsComAdminMixin, ComTabsMixin, PosterCreateBaseView):
class PosterCreateView(PosterCreateBaseView):
"""Create communication poster."""
success_url = reverse_lazy("com:poster_list")
@ -701,7 +704,7 @@ class PosterCreateView(IsComAdminMixin, ComTabsMixin, PosterCreateBaseView):
return kwargs
class PosterEditView(IsComAdminMixin, ComTabsMixin, PosterEditBaseView):
class PosterEditView(PosterEditBaseView):
"""Edit communication poster."""
success_url = reverse_lazy("com:poster_list")
@ -712,13 +715,13 @@ class PosterEditView(IsComAdminMixin, ComTabsMixin, PosterEditBaseView):
return kwargs
class PosterDeleteView(IsComAdminMixin, ComTabsMixin, PosterDeleteBaseView):
class PosterDeleteView(PosterDeleteBaseView):
"""Delete communication poster."""
success_url = reverse_lazy("com:poster_list")
class PosterModerateListView(IsComAdminMixin, ComTabsMixin, ListView):
class PosterModerateListView(PosterAdminViewMixin, ListView):
"""Moderate list communication poster."""
current_tab = "posters"
@ -732,7 +735,7 @@ class PosterModerateListView(IsComAdminMixin, ComTabsMixin, ListView):
return kwargs
class PosterModerateView(IsComAdminMixin, ComTabsMixin, View):
class PosterModerateView(PosterAdminViewMixin, View):
"""Moderate communication poster."""
def get(self, request, *args, **kwargs):

View File

@ -17,7 +17,16 @@ 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 BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
from core.models import (
BanGroup,
Group,
OperationLog,
Page,
QuickUploadImage,
SithFile,
User,
UserBan,
)
admin.site.unregister(AuthGroup)
@ -79,9 +88,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date", "is_in_sas")
list_display = ("name", "owner", "size", "date")
autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name", "parent__name")
search_fields = ("name",)
@admin.register(OperationLog)
@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin):
list_display = ("label", "operator", "operation_type", "date")
search_fields = ("label", "date", "operation_type")
autocomplete_fields = ("operator",)
@admin.register(QuickUploadImage)
class QuickUploadImageAdmin(admin.ModelAdmin):
list_display = ("uuid", "uploader", "created_at", "name")
search_fields = ("uuid", "uploader", "name")
autocomplete_fields = ("uploader",)
readonly_fields = ("width", "height", "size")

View File

@ -1,23 +1,25 @@
from typing import Annotated
from typing import Annotated, Any, Literal
import annotated_types
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import Query
from ninja import File, Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing
from core.auth.api_permissions import CanAccessLookup, CanView
from core.models import Group, SithFile, User
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
UploadedImage,
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
@ -33,6 +35,25 @@ class MarkdownController(ControllerBase):
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/upload")
class UploadController(ControllerBase):
@route.post(
"/image",
response={
200: UploadedFileSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
403: dict[Literal["detail"], str],
},
permissions=[HasPerm("core.add_quickuploadimage")],
url_name="quick_upload_image",
)
def upload_image(self, file: File[UploadedImage]):
image = QuickUploadImage.create_from_uploaded(
file, uploader=self.context.request.user
)
return image
@api_controller("/mailings")
class MailingListController(ControllerBase):
@route.get("", response=str)
@ -73,7 +94,7 @@ class SithFileController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
return SithFile.objects.filter(name__icontains=search)
@api_controller("/group")

View File

@ -32,7 +32,6 @@ class SithConfig(AppConfig):
verbose_name = "Core app of the Sith"
def ready(self):
import core.signals # noqa F401
from forum.models import Forum
cache.clear()

View File

@ -169,10 +169,9 @@ class CanCreateMixin(View):
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
return super().dispatch(request, *arg, **kwargs)
def form_valid(self, form):
obj = form.instance
@ -228,6 +227,19 @@ class FormerSubscriberMixin(AccessMixin):
return super().dispatch(request, *args, **kwargs)
class IsSubscriberMixin(AccessMixin):
"""Check if the user is a subscriber.
Raises:
PermissionDenied: if the user isn't subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the object author.

View File

@ -36,21 +36,12 @@ from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
from accounting.models import (
AccountingType,
BankAccount,
ClubAccount,
Company,
GeneralJournal,
Operation,
SimplifiedAccountingType,
)
from club.models import Club, Membership
from com.calendar import IcsCalendar
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
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 counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UV
@ -118,12 +109,8 @@ class Command(BaseCommand):
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create(
id=1,
name=settings.SITH_MAIN_CLUB["name"],
unix_name=settings.SITH_MAIN_CLUB["unix_name"],
address=settings.SITH_MAIN_CLUB["address"],
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
main_club.board_group.permissions.add(
*Permission.objects.filter(
@ -131,16 +118,9 @@ class Command(BaseCommand):
)
)
bar_club = Club.objects.create(
id=2,
name=settings.SITH_BAR_MANAGER["name"],
unix_name=settings.SITH_BAR_MANAGER["unix_name"],
address=settings.SITH_BAR_MANAGER["address"],
)
Club.objects.create(
id=84,
name=settings.SITH_LAUNDERETTE_MANAGER["name"],
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"],
address=settings.SITH_LAUNDERETTE_MANAGER["address"],
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
self.reset_index("club")
@ -305,13 +285,7 @@ class Command(BaseCommand):
page=services_page,
title="Services",
author=skia,
content="""
| | | |
| :---: | :---: | :---: |
| [Eboutic](/eboutic) | [Laverie](/launderette) | Matmat |
| SAS | Weekmail | Forum|
""",
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
)
index_page = Page(name="Index")
@ -320,23 +294,10 @@ class Command(BaseCommand):
page=index_page,
title="Wiki index",
author=root,
content="""
Welcome to the wiki page!
""",
content="Welcome to the wiki page!",
)
laundry_page = Page(name="launderette")
laundry_page.save(force_lock=True)
PageRev.objects.create(
page=laundry_page,
title="Laverie",
author=root,
content="Fonctionnement de la laverie",
)
groups.public.viewable_page.set(
[syntax_page, services_page, index_page, laundry_page]
)
groups.public.viewable_page.set([syntax_page, services_page, index_page])
self._create_subscription(root)
self._create_subscription(skia)
@ -353,31 +314,17 @@ Welcome to the wiki page!
# Clubs
Club.objects.create(
name="Bibo'UT",
unix_name="bibout",
address="46 de la Boustifaille",
parent=main_club,
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
)
guyut = Club.objects.create(
name="Guy'UT",
unix_name="guyut",
address="42 de la Boustifaille",
parent=main_club,
)
Club.objects.create(
name="Woenzel'UT", unix_name="woenzel", address="Woenzel", parent=guyut
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
)
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
troll = Club.objects.create(
name="Troll Penché",
unix_name="troll",
address="Terre Du Milieu",
parent=main_club,
name="Troll Penché", address="Terre Du Milieu", parent=main_club
)
refound = Club.objects.create(
name="Carte AE",
unix_name="carte_ae",
address="Jamais imprimée",
parent=main_club,
name="Carte AE", address="Jamais imprimée", parent=main_club
)
Membership.objects.create(user=skia, club=main_club, role=3)
@ -470,7 +417,6 @@ Welcome to the wiki page!
limit_age=18,
)
cons = Product.objects.create(
id=settings.SITH_ECOCUP_CONS,
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
@ -480,7 +426,6 @@ Welcome to the wiki page!
club=main_club,
)
dcons = Product.objects.create(
id=settings.SITH_ECOCUP_DECO,
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
@ -509,6 +454,14 @@ Welcome to the wiki page!
club=main_club,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
@ -521,81 +474,10 @@ Welcome to the wiki page!
eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=refound,
)
# Accounting test values:
BankAccount.objects.create(name="AE TG", club=main_club)
BankAccount.objects.create(name="Carte AE", club=main_club)
ba = BankAccount.objects.create(name="AE TI", club=main_club)
ca = ClubAccount.objects.create(
name="Troll Penché", bank_account=ba, club=troll
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
gj = GeneralJournal.objects.create(
name="A16", start_date=date.today(), club_account=ca
)
credit = AccountingType.objects.create(
code="74", label="Subventions d'exploitation", movement_type="CREDIT"
)
debit = AccountingType.objects.create(
code="606",
label="Achats non stockés de matières et fournitures(*1)",
movement_type="DEBIT",
)
debit2 = AccountingType.objects.create(
code="604",
label="Achats d'études et prestations de services(*2)",
movement_type="DEBIT",
)
buying = AccountingType.objects.create(
code="60", label="Achats (sauf 603)", movement_type="DEBIT"
)
comptes = AccountingType.objects.create(
code="6", label="Comptes de charge", movement_type="DEBIT"
)
SimplifiedAccountingType.objects.create(
label="Je fais du simple 6", accounting_type=comptes
)
woenzco = Company.objects.create(name="Woenzel & co")
operation_list = [
(27, "J'avais trop de bière", "CASH", buying, "USER", skia.id, None),
(4000, "Pas une opération", "CHECK", debit, "COMPANY", woenzco.id, 23),
(22, "C'est de l'argent ?", "CARD", credit, "CLUB", troll.id, None),
(37, "Je paye CASH", "CASH", debit2, "OTHER", None, None),
(300, "Paiement Guy", "CASH", buying, "USER", skia.id, None),
(32.3, "Essence", "CASH", buying, "OTHER", None, None),
(46.42, "Allumette", "CHECK", credit, "CLUB", main_club.id, 57),
(666.42, "Subvention club", "CASH", comptes, "CLUB", main_club.id, None),
(496, "Ça, c'est un 6", "CARD", comptes, "USER", skia.id, None),
(17, "La Gargotte du Korrigan", "CASH", debit2, "CLUB", bar_club.id, None),
]
operations = [
Operation(
number=index,
journal=gj,
date=localdate(),
amount=op[0],
remark=op[1],
mode=op[2],
done=True,
accounting_type=op[3],
target_type=op[4],
target_id=op[5],
target_label="" if op[4] != "OTHER" else "Autre source",
cheque_number=op[6],
)
for index, op in enumerate(operation_list, start=1)
]
for operation in operations:
operation.clean()
Operation.objects.bulk_create(operations)
# Add barman to counter
Counter.sellers.through.objects.bulk_create(
@ -809,32 +691,21 @@ Welcome to the wiki page!
# SAS
for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir():
album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
album = Album.objects.create(name=f.name, is_moderated=True)
for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture(
parent=album,
name=p.name,
file=file,
original=file,
owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True,
mime_type="image/webp",
size=file.size,
)
pict.file.name = p.name
pict.clean()
pict.original.name = pict.name
pict.generate_thumbnails()
pict.full_clean()
pict.save()
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@ -919,6 +790,8 @@ Welcome to the wiki page!
"view_album",
"view_peoplepicturerelation",
"add_peoplepicturerelation",
"add_page",
"add_quickuploadimage",
]
)
)
@ -929,8 +802,7 @@ Welcome to the wiki page!
accounting_admin.permissions.add(
*list(
perms.filter(
Q(content_type__app_label="accounting")
| Q(
Q(
codename__in=[
"view_customer",
"view_product",
@ -959,7 +831,7 @@ Welcome to the wiki page!
counter_admin.permissions.add(
*list(
perms.filter(
Q(content_type__app_label__in=["counter", "launderette"])
Q(content_type__app_label__in=["counter"])
& ~Q(codename__in=["delete_product", "delete_producttype"])
)
)

View File

@ -64,12 +64,12 @@ class Command(BaseCommand):
)
)
self.make_club(
Club.objects.get(unix_name="ae"),
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(unix_name="troll"),
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
@ -235,7 +235,7 @@ class Command(BaseCommand):
categories = list(
ProductType.objects.filter(name__in=[c.name for c in categories])
)
ae = Club.objects.get(unix_name="ae")
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list(
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])

View File

@ -0,0 +1,56 @@
# Generated by Django 4.2.20 on 2025-04-10 09:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0044_alter_userban_options"),
]
operations = [
migrations.CreateModel(
name="QuickUploadImage",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("uuid", models.UUIDField(db_index=True, unique=True)),
("name", models.CharField(max_length=100)),
(
"image",
models.ImageField(
height_field="height",
unique=True,
upload_to="upload/%Y/%m/%d",
width_field="width",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
("width", models.PositiveIntegerField(verbose_name="width")),
("height", models.PositiveIntegerField(verbose_name="height")),
("size", models.PositiveIntegerField(verbose_name="size")),
(
"uploader",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="quick_uploads",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0045_quickuploadimage"),
("sas", "0006_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]

View File

@ -0,0 +1,9 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0046_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]

View File

@ -17,13 +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 __future__ import annotations
import importlib
import logging
import os
import string
@ -32,6 +31,7 @@ from datetime import timedelta
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self
from uuid import uuid4
from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager
@ -41,6 +41,8 @@ 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.files import File
from django.core.files.base import ContentFile
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
@ -48,12 +50,14 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.module_loading import import_string
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image
from PIL import Image, ImageOps
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt
from club.models import Club
@ -337,8 +341,8 @@ class User(AbstractUser):
return reverse("core:user_profile", kwargs={"user_id": self.pk})
def promo_has_logo(self) -> bool:
return Path(
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
return (
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo:02d}.png"
).exists()
@cached_property
@ -392,19 +396,10 @@ class User(AbstractUser):
return self.is_root
return group in self.cached_groups
@property
@cached_property
def cached_groups(self) -> list[Group]:
"""Get the list of groups this user is in.
The result is cached for the default duration (should be 5 minutes)
Returns: A list of all the groups this user is in.
"""
groups = cache.get(f"user_{self.id}_groups")
if groups is None:
groups = list(self.groups.all())
cache.set(f"user_{self.id}_groups", groups)
return groups
"""Get the list of groups this user is in."""
return list(self.groups.all())
@cached_property
def is_root(self) -> bool:
@ -417,18 +412,6 @@ class User(AbstractUser):
def is_board_member(self) -> bool:
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
@cached_property
def is_launderette_manager(self):
from club.models import Club
return (
Club.objects.filter(
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
)
.first()
.get_membership_for(self)
)
@cached_property
def is_banned_alcohol(self) -> bool:
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
@ -676,10 +659,6 @@ class AnonymousUser(AuthAnonymousUser):
def is_board_member(self):
return False
@property
def is_launderette_manager(self):
return False
@property
def is_banned_alcohol(self):
return False
@ -867,9 +846,6 @@ class SithFile(models.Model):
on_delete=models.CASCADE,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta:
verbose_name = _("file")
@ -878,24 +854,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
copy_rights = False
if self.id is None:
copy_rights = True
adding = self._state.adding
super().save(*args, **kwargs)
if copy_rights:
if adding:
self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
@ -908,8 +870,6 @@ class SithFile(models.Model):
return user.is_board_member
if user.is_com_admin:
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool:
@ -936,8 +896,6 @@ class SithFile(models.Model):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or (
self.parent is not None and self in self.get_parent_list()
):
@ -1075,18 +1033,6 @@ class SithFile(models.Model):
def is_file(self):
return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self):
parents = []
current = self.parent
@ -1108,6 +1054,68 @@ class SithFile(models.Model):
return reverse("core:download", kwargs={"file_id": self.id})
class QuickUploadImage(models.Model):
"""Images uploaded by user outside of the SithFile mechanism"""
IMAGE_NAME_SIZE = 100
MAX_IMAGE_SIZE = 600 # Maximum px on width / length
uuid = models.UUIDField(unique=True, db_index=True)
name = models.CharField(max_length=IMAGE_NAME_SIZE, blank=False)
image = models.ImageField(
upload_to="upload/%Y/%m/%d",
width_field="width",
height_field="height",
unique=True,
)
uploader = models.ForeignKey(
"User",
related_name="quick_uploads",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
width = models.PositiveIntegerField(_("width"))
height = models.PositiveIntegerField(_("height"))
size = models.PositiveIntegerField(_("size"))
def __str__(self) -> str:
return str(self.image.path)
def get_absolute_url(self):
return self.image.url
@classmethod
def create_from_uploaded(
cls, image: UploadedFile, uploader: User | None = None
) -> Self:
def convert_image(file: UploadedFile) -> ContentFile:
content = BytesIO()
image = Image.open(BytesIO(file.read()))
if image.width > cls.MAX_IMAGE_SIZE or image.height > cls.MAX_IMAGE_SIZE:
image = ImageOps.contain(image, (600, 600))
image.save(fp=content, format="webp", optimize=True)
return ContentFile(content.getvalue())
identifier = str(uuid4())
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
file = File(convert_image(image), name=f"{identifier}.webp")
width, height = Image.open(file).size
return cls.objects.create(
uuid=identifier,
name=name,
image=file,
uploader=uploader,
size=file.size,
)
def delete(self, *args, **kwargs):
self.image.delete(save=False)
return super().delete(*args, **kwargs)
class LockError(Exception):
"""There was a lock error on the object."""
@ -1366,6 +1374,18 @@ class PageRev(models.Model):
class Meta:
ordering = ["date"]
def __getattribute__(self, attr):
if attr == "owner_group":
return self.page.owner_group
elif attr == "edit_groups":
return self.page.edit_groups
elif attr == "view_groups":
return self.page.view_groups
elif attr == "unset_lock":
return self.page.unset_lock
else:
return object.__getattribute__(self, attr)
def __str__(self):
return str(self.__dict__)
@ -1379,18 +1399,6 @@ class PageRev(models.Model):
def get_absolute_url(self):
return reverse("core:page", kwargs={"page_name": self.page._full_name})
def __getattribute__(self, attr):
if attr == "owner_group":
return self.page.owner_group
elif attr == "edit_groups":
return self.page.edit_groups
elif attr == "view_groups":
return self.page.view_groups
elif attr == "unset_lock":
return self.page.unset_lock
else:
return object.__getattribute__(self, attr)
def can_be_edited_by(self, user):
return self.page.can_be_edited_by(user)
@ -1413,22 +1421,24 @@ class Notification(models.Model):
return self.get_type_display()
def save(self, *args, **kwargs):
if not self.id and self.type in settings.SITH_PERMANENT_NOTIFICATIONS:
if self._state.adding and self.type in settings.SITH_PERMANENT_NOTIFICATIONS:
old_notif = self.user.notifications.filter(type=self.type).last()
if old_notif:
old_notif.callback()
old_notif.save()
return
# if this permanent notification is the first one,
# go into the callback nonetheless, because the logic
# to set Notification.param is here
# (please don't be mad at me, I'm not the one who cooked this spaghetti)
self.callback()
super().save(*args, **kwargs)
def callback(self):
# Get the callback defined in settings to update existing
# notifications
mod_name, func_name = settings.SITH_PERMANENT_NOTIFICATIONS[self.type].rsplit(
".", 1
)
mod = importlib.import_module(mod_name)
getattr(mod, func_name)(self)
func_name = settings.SITH_PERMANENT_NOTIFICATIONS.get(self.type)
if not func_name:
return
import_string(func_name)(self)
class Gift(models.Model):

View File

@ -1,16 +1,29 @@
from pathlib import Path
from typing import Annotated
from typing import Annotated, Any
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 django.utils.translation import gettext as _
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo
from core.models import Group, SithFile, User
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import is_image
class UploadedImage(UploadedFile):
@classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any:
super()._validate(v, info)
if not is_image(v):
msg = _("This file is not a valid image")
raise ValueError(msg)
return v
class SimpleUserSchema(ModelSchema):
@ -47,6 +60,18 @@ class UserProfileSchema(ModelSchema):
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
class UploadedFileSchema(ModelSchema):
class Meta:
model = QuickUploadImage
fields = ["uuid", "name", "width", "height", "size"]
href: str
@staticmethod
def resolve_href(obj: QuickUploadImage) -> str:
return obj.get_absolute_url()
class SithFileSchema(ModelSchema):
class Meta:
model = SithFile

View File

@ -1,15 +0,0 @@
from django.core.cache import cache
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from core.models import User
@receiver(m2m_changed, sender=User.groups.through, dispatch_uid="user_groups_changed")
def user_groups_changed(sender, instance: User, **kwargs):
"""Clear the cached groups of the user."""
# As a m2m relationship doesn't live within the model
# but rather on an intermediary table, there is no
# model method to override, meaning we must use
# a signal to invalidate the cache when a user is removed from a group
cache.delete(f"user_{instance.pk}_groups")

View File

@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { markdownRenderMarkdown } from "#openapi";
import {
type UploadUploadImageErrors,
markdownRenderMarkdown,
uploadUploadImage,
} from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
new EasyMDE({
const easymde = new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false,
uploadImage: true,
imagePathAbsolute: false,
imageUploadFunction: async (file, onSuccess, onError) => {
const response = await uploadUploadImage({
body: {
file: file,
},
});
if (!response.response.ok) {
if (response.response.status === 422) {
onError(
(response.error as UploadUploadImageErrors[422]).detail
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.join(" ; "),
);
} else if (response.response.status === 403) {
onError(gettext("You are not authorized to use this feature"));
} else {
onError(gettext("Could not upload image"));
}
return;
}
onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url)
let cursor = easymde.codemirror.getCursor();
easymde.codemirror.setSelection({
line: cursor.line,
ch: cursor.ch - response.data.href.length - 3,
});
easymde.codemirror.replaceSelection(response.data.name);
// Move cursor at the end of the url and add a new line
cursor = easymde.codemirror.getCursor();
easymde.codemirror.setSelection({
line: cursor.line,
ch: cursor.ch + response.data.href.length + 3,
});
easymde.codemirror.replaceSelection("\n");
},
previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
sbOnDragEnter: gettext("Drop image to upload it."),
sbOnDrop: gettext("Uploading image #images_names# …"),
sbProgress: gettext("Uploading #file_name#: #progress#%"),
sbOnUploaded: gettext("Uploaded #image_name#"),
sizeUnits: gettext(" B, KB, MB"),
},
toolbar: [
{
name: "heading-smaller",
@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
className: "fa-regular fa-image",
title: gettext("Insert image"),
},
{
name: "upload-image",
action: EasyMDE.drawUploadedImage,
className: "fa-solid fa-file-arrow-up",
title: gettext("Upload image"),
},
{
name: "table",
action: EasyMDE.drawTable,

View File

@ -0,0 +1,174 @@
import {
type Placement,
autoPlacement,
computePosition,
flip,
offset,
size,
} from "@floating-ui/dom";
/**
* Library usage:
*
* Add a `tooltip` attribute to any html element with it's tooltip text
* You can control the position of the tooltp with the `tooltip-position` attribute
* Allowed placements are `auto`, `top`, `right`, `bottom`, `left`
* You can add `-start` and `-end` to all allowed placement values except for `auto`
* Default placement is `auto`
* Note: placement are suggestions and the position could change if the popup gets
* outside of the screen.
*
* You can customize your tooltip by passing additional classes or ids to it
* You can use `tooltip-class` and `tooltip-id` to add additional elements to the
* `class` and `id` attribute of the generated tooltip
*
* @example
* ```html
* <p tooltip="tooltip text"></p>
* <p tooltip="tooltip left" tooltip-position="left"></p>
* <div tooltip="tooltip custom class" tooltip-class="custom custom-class"></div>
* ```
**/
type Status = "open" | "close";
const tooltips: Map<HTMLElement, HTMLElement> = new Map();
function getPosition(element: HTMLElement): Placement | "auto" {
const position = element.getAttribute("tooltip-position");
if (position) {
return position as Placement | "auto";
}
return "auto";
}
function getMiddleware(element: HTMLElement) {
const middleware = [offset(6), size()];
if (getPosition(element) === "auto") {
middleware.push(autoPlacement());
} else {
middleware.push(flip());
}
return { middleware: middleware };
}
function getPlacement(element: HTMLElement) {
const position = getPosition(element);
if (position !== "auto") {
return { placement: position };
}
return {};
}
function createTooltip(element: HTMLElement) {
const tooltip = document.createElement("div");
document.body.append(tooltip);
tooltips.set(element, tooltip);
return tooltip;
}
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip");
for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] },
]) {
const populated = attributes.default;
if (element.hasAttribute(attributes.src)) {
populated.push(...element.getAttribute(attributes.src).split(" "));
}
tooltip.setAttribute(attributes.dst, populated.join(" "));
}
}
function getTooltip(element: HTMLElement) {
const tooltip = tooltips.get(element);
if (tooltip === undefined) {
return createTooltip(element);
}
return tooltip;
}
function tooltipMouseover(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
const tooltip = getTooltip(target);
updateTooltip(target, tooltip, "open");
computePosition(target, tooltip, {
...getPlacement(target),
...getMiddleware(target),
}).then(({ x, y }) => {
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
function tooltipMouseout(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
updateTooltip(target, getTooltip(target), "close");
}
window.addEventListener("DOMContentLoaded", () => {
for (const el of document.querySelectorAll("[tooltip]")) {
el.addEventListener("mouseover", tooltipMouseover);
el.addEventListener("mouseout", tooltipMouseout);
}
});
// Add / remove callback when tooltip attribute is added / removed
new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) {
const target = mutation.target as HTMLElement;
target.removeEventListener("mouseover", tooltipMouseover);
target.removeEventListener("mouseout", tooltipMouseout);
if (target.hasAttribute("tooltip")) {
target.addEventListener("mouseover", tooltipMouseover);
target.addEventListener("mouseout", tooltipMouseout);
if (target.matches(":hover")) {
target.dispatchEvent(new Event("mouseover", { bubbles: true }));
} else {
target.dispatchEvent(new Event("mouseout", { bubbles: true }));
}
} else if (tooltips.has(target)) {
// Remove corresponding tooltip
tooltips.get(target).remove();
tooltips.delete(target);
}
}
}).observe(document.body, {
attributes: true,
attributeFilter: ["tooltip", "tooltip-class", "toolitp-position", "tooltip-id"],
subtree: true,
});
// Remove orphan tooltips
new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node.nodeType !== node.ELEMENT_NODE) {
continue;
}
const target = node as HTMLElement;
if (!target.hasAttribute("tooltip")) {
continue;
}
if (tooltips.has(target)) {
tooltips.get(target).remove();
tooltips.delete(target);
}
}
}
}).observe(document.body, {
subtree: true,
childList: true,
});

View File

@ -0,0 +1,3 @@
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();

View File

@ -1,4 +1,4 @@
import type { Client, Options, RequestResult } from "@hey-api/client-fetch";
import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
import { client } from "#openapi";
export interface PaginatedResponse<T> {
@ -30,7 +30,7 @@ export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
): Promise<T[]> => {
const maxPerPage = 199;
const maxPerPage = 200;
const queryParams = options ?? ({} as PaginatedRequest);
queryParams.query = queryParams.query ?? {};
queryParams.query.page_size = maxPerPage;
@ -54,9 +54,8 @@ export const paginated = async <T>(
return results;
};
interface Request {
interface Request extends TDataShape {
client?: Client;
url: string;
}
interface InterceptorOptions {

View File

@ -0,0 +1,89 @@
details.accordion>summary {
margin: 2px 0 0 0;
padding: .5em .5em .5em .7em;
cursor: pointer;
user-select: none;
display: block;
border-top-right-radius: 3px;
border-top-left-radius: 3px;
}
details[open].accordion>summary {
border: 1px solid #003eff;
background: #007fff;
color: #ffffff;
}
details:not([open]).accordion>summary {
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
border: 1px solid #c5c5c5;
background: #f6f6f6;
color: #454545;
}
details.accordion>summary::before {
font-family: FontAwesome;
content: '\f0da';
margin-right: 5px;
transition: 700ms;
font-size: 0.8em;
}
details[open].accordion>summary::before {
font-family: FontAwesome;
content: '\f0d7';
}
details.accordion>.accordion-content {
background: #ffffff;
color: #333333;
padding: 1em 2.2em;
border: 1px solid #dddddd;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
overflow: hidden;
}
@mixin animation($selector) {
details.accordion#{$selector} {
opacity: 0;
@supports (max-height: calc-size(max-content, size)) {
max-height: 0px;
}
}
details[open].accordion#{$selector} {
opacity: 1;
// Setting a transition on all states of the content
// will create a strange behavior where the transition
// continues without being shown, creating inconsistenties
transition: all 300ms ease-out;
@supports (max-height: calc-size(max-content, size)) {
max-height: calc-size(max-content, size);
}
}
}
// ::details-content isn't available on firefox yet
// we use .accordion-content as a workaround
// But we need to use ::details-content for chrome because it's
// not working correctly otherwise
// it only happen in chrome, not safari or firefox
// Note: `selector` is not supported by scss so we comment it out to
// avoid compiling it and sending it straight to the css
// This is a trick that comes from here :
// https://stackoverflow.com/questions/62665318/using-supports-selector-despite-sass-not-supporting-it
@supports #{'selector(details::details-content)'} {
@include animation("::details-content")
}
@supports #{'not selector(details::details-content)'} {
@include animation(">.accordion-content")
}

View File

@ -55,6 +55,14 @@
width: 80%;
}
.card-top-left {
position: absolute;
top: 10px;
right: 10px;
padding: 10px;
text-align: center;
}
.card-content {
color: black;
display: flex;

View File

@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
flex-direction: row;
gap: 10px;
> a {
color: $text-color!important;
>a {
color: $text-color !important;
}
&:hover>a {
@ -106,6 +106,7 @@ $hovered-red-text-color: #ff4d4d;
color: $text-color;
font-weight: normal;
line-height: 1.3em;
font-family: "Twemoji Country Flags", sans-serif;
&:hover {
background-color: $background-color-hovered;
@ -206,7 +207,7 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-end;
}
> a {
>a {
display: block;
min-width: 40px;
height: 40px;
@ -250,21 +251,35 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-start;
}
>a {
a,
button {
font-size: 100%;
margin: 0;
text-align: right;
color: $text-color;
margin-top: auto;
&:hover {
color: $hovered-text-color;
}
}
&:last-child {
color: $red-text-color;
form#logout-form {
margin: 0;
display: inline;
}
&:hover {
color: $hovered-red-text-color;
}
#logout-form button {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -15,6 +15,7 @@
ol,
p {
line-height: 22px;
word-break: break-word;
}
code {
@ -71,7 +72,8 @@
a:hover {
text-decoration: underline;
}
.footnotes {
font-size: 85%;
}
}
}

View File

@ -1,5 +1,7 @@
@import "colors";
$desktop-size: 500px;
nav.navbar {
background-color: $primary-dark-color;
margin: 1em;
@ -7,15 +9,24 @@ nav.navbar {
border-radius: 0.6em;
min-height: 40px;
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
position: relative;
flex-direction: column;
align-items: flex-start;
gap: 0;
margin: .2em;
>.content[mobile-display="hidden"] {
display: none;
}
>.content[mobile-display="revealed"] {
display: block;
}
}
> .expand-button {
>.expand-button {
background-color: transparent;
display: none;
position: relative;
@ -27,27 +38,27 @@ nav.navbar {
align-items: center;
margin: 0;
> i {
>i {
font-size: 1.5em;
color: white;
}
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
display: flex;
}
}
> .content {
@media (min-width: 500px) {display: flex;
>.content {
@media (min-width: $desktop-size) {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
display: flex !important;
display: flex;
}
> .menu,
> .link {
>.menu,
>.link {
box-sizing: border-box;
width: 130px;
height: 52px;
@ -56,7 +67,7 @@ nav.navbar {
justify-content: center;
cursor: pointer;
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
width: 100%;
height: auto;
justify-content: flex-start;
@ -64,80 +75,75 @@ nav.navbar {
&:last-child {
border-radius: 0 0 .6em .6em;
> .content {
>.content {
box-shadow: 3px 3px 3px 0 #dfdfdf;
}
}
}
}
> .menu > .head,
> .link {
>.menu>.head,
>.link {
color: white;
padding: 10px 20px;
box-sizing: border-box;
}
@media (max-width: 500px) {
>.menu>summary,
>.link {
@media (max-width: $desktop-size) {
padding: 10px;
}
}
>.link {
padding: 10px 20px;
box-sizing: border-box;
}
.link:hover,
.menu:hover {
background-color: rgba(0, 0, 0, .2);
}
> .menu > .head,
> .link {
color: white;
padding: 10px 20px;
box-sizing: border-box;
details.menu {
cursor: pointer;
user-select: none;
z-index: 10;
align-items: center;
display: inline-block;
@media (max-width: 500px) {
padding: 10px;
summary {
list-style: none;
display: flex;
align-items: center;
height: 100%;
padding-left: 20px;
padding-right: 20px;
@media (min-width: $desktop-size) {
justify-content: center;
}
}
}
.link:hover,
.menu:hover {
background-color: rgba(0, 0, 0, .2);
}
> .menu:hover > .content,
> .menu > .head:hover + .content,
> .menu > .content:hover {
display: flex;
}
> .menu {
display: flex;
position: relative;
> .content {
z-index: 10;
summary::-webkit-details-marker {
display: none;
position: absolute;
top: 100%;
background-color: white;
margin: 0;
list-style-type: none;
width: 130px;
box-shadow: 3px 3px 3px 0 #dfdfdf;
flex-direction: column;
}
@media (max-width: 500px) {
position: absolute;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
box-shadow: inset 3px 3px 3px 0 #dfdfdf;
ul.content {
list-style-type: none;
background: white;
margin: 0;
@media (min-width: $desktop-size) {
box-shadow: 3px 3px 3px 0 #dfdfdf;
}
> li > a {
>li>a {
display: flex;
padding: 15px 20px;
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
padding: 10px;
}

View File

@ -45,32 +45,6 @@ body {
}
}
[tooltip] {
position: relative;
}
[tooltip]::before {
@include shadow;
opacity: 0;
z-index: 1;
content: attr(tooltip);
background: hsl(219.6, 20.8%, 96%);
color: $black-color;
border: 0.5px solid hsl(0, 0%, 50%);
;
border-radius: 5px;
padding: 5px;
top: 1em;
position: absolute;
margin-top: 5px;
white-space: nowrap;
transition: opacity 500ms ease-out;
}
[tooltip]:hover::before {
opacity: 1;
}
.ib {
display: inline-block;
padding: 1px;
@ -330,19 +304,22 @@ body {
align-items: center;
text-align: justify;
&.alert-yellow {
&.alert-yellow,
&.alert-warning {
background-color: rgb(255, 255, 240);
color: rgb(99, 87, 6);
border: rgb(192, 180, 16) 1px solid;
}
&.alert-green {
&.alert-green,
&.alert-success {
background-color: rgb(245, 255, 245);
color: rgb(3, 84, 63);
border: rgb(14, 159, 110) 1px solid;
}
&.alert-red {
&.alert-red,
&.alert-error {
background-color: rgb(255, 245, 245);
color: #c53030;
border: #fc8181 1px solid;
@ -492,33 +469,6 @@ body {
}
}
/*---------------------------ACCOUNTING----------------------------*/
#accounting {
.journal-table {
tbody {
.neg-amount {
color: red;
&:before {
font-family: FontAwesome;
font-size: 1em;
content: "\f063";
}
}
.pos-amount {
color: green;
&:before {
font-family: FontAwesome;
font-size: 1em;
content: "\f062";
}
}
}
}
}
/*-----------------------------GENERAL-----------------------------*/
h1,
h2,
@ -815,12 +765,6 @@ textarea {
margin-top: 10px;
}
/*---------------------------LAUNDERETTE-------------------------------*/
#token_form label {
display: inline;
}
/*--------------------------------FOOTER-------------------------------*/
footer {

View File

@ -0,0 +1,26 @@
@import "colors";
.tooltip {
@include shadow;
z-index: 1;
pointer-events: none;
background-color: #333;
color: #fff;
border: 0.5px solid hsl(0, 0%, 50%);
border-radius: 5px;
padding: 5px 10px;
position: absolute;
white-space: nowrap;
opacity: 0;
transition: opacity 500ms ease-out;
white-space: normal;
left: 0;
top: 0;
}
.tooltip[tooltip-status=open] {
opacity: 1;
transition: opacity 500ms ease-in;
}

View File

@ -24,13 +24,21 @@ body {
background-color: white;
margin: 0;
> .title {
.alert {
word-wrap: break-word;
white-space: normal;
text-align: center;
display: block;
width: fit-content;
}
>.title {
text-align: center;
margin: 0;
}
> div,
> form {
>div,
>form {
box-sizing: border-box;
display: flex;
flex-direction: column;
@ -41,8 +49,8 @@ body {
max-width: 500px;
margin-top: 20px;
> p,
> div {
>p,
>div {
display: flex;
flex-direction: column;
justify-content: center;
@ -50,7 +58,7 @@ body {
width: 100%;
margin: 0;
> label {
>label {
width: 100%;
@media (min-width: 500px) {
@ -59,9 +67,9 @@ body {
}
}
> input,
> p > input,
> div > input {
>input,
>p>input,
>div>input {
box-sizing: border-box;
width: 100%;
max-width: 500px;
@ -71,35 +79,35 @@ body {
}
}
> .errorlist {
>.errorlist {
color: red;
text-align: center;
margin: 10px 0 0 0;
list-style-type: none;
}
> .required > .helptext {
>.required>.helptext {
text-align: center;
font-style: italic;
}
> .required:last-of-type {
>.required:last-of-type {
box-sizing: border-box;
max-width: 300px;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
> label {
>label {
width: 100%;
}
> img {
>img {
width: 70px;
object-fit: contain;
}
> input {
>input {
width: 200px;
}
}

View File

@ -7,10 +7,12 @@
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
<link rel="stylesheet" href="{{ static('core/accordion.scss') }}">
{% block jquery_css %}
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
@ -23,6 +25,8 @@
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
@ -83,18 +87,18 @@
</ul>
<div id="content">
{% block tabs %}
{%- block tabs -%}
{% include "core/base/tabs.jinja" %}
{% endblock %}
{%- endblock -%}
{% block errors%}
{%- block errors -%}
{% if error %}
{{ error }}
{% endif %}
{% endblock %}
{%- endblock -%}
{% block content %}
{% endblock %}
{%- block content -%}
{%- endblock -%}
</div>
</div>
@ -119,10 +123,27 @@
{% block script %}
<script>
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isMobile = () => {
return window.innerWidth >= 500;
}
for (const item of menuItems){
item.addEventListener("mouseover", () => {
if (isMobile()){
item.setAttribute("open", "");
}
})
item.addEventListener("mouseout", () => {
if (isMobile()){
item.removeAttribute("open");
}
})
}
function showMenu() {
let navbar = document.getElementById("navbar-content");
const current = navbar.style.getPropertyValue("display");
navbar.style.setProperty("display", current === "none" ? "block" : "none");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden")
}
document.addEventListener("keydown", (e) => {

View File

@ -59,7 +59,10 @@
</div>
<div class="links">
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<a href="{{ url('core:logout') }}">{% trans %}Logout{% endtrans %}</a>
<form id="logout-form" method="post" action="{{ url("core:logout") }}">
{% csrf_token %}
<button type="submit">{% trans %}Logout{% endtrans %}</button>
</form>
</div>
</div>
<a

View File

@ -1,48 +1,47 @@
<nav class="navbar">
<button class="expand-button" onclick="showMenu()"><i class="fa fa-bars"></i></button>
<div id="navbar-content" class="content" style="display: none;">
<div id="navbar-content" class="content" mobile-display="hidden">
<a class="link" href="{{ url('core:index') }}">{% trans %}Main{% endtrans %}</a>
<div class="menu">
<span class="head">{% trans %}Associations & Clubs{% endtrans %}</span>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}Events{% endtrans %}</span>
</details>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Events{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('election:list') }}">{% trans %}Elections{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='ga') }}">{% trans %}Big event{% endtrans %}</a></li>
</ul>
</div>
</details>
<a class="link" href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
<a class="link" href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
<a class="link" href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
<div class="menu">
<span class="head">{% trans %}Services{% endtrans %}</span>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="/launderette">{% trans %}Launderette{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}My Benefits{% endtrans %}</span>
</details>
<details name="navbar" class="menu">
<summary class="head">{% trans %}My Benefits{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('core:page', page_name='partenaires')}}">{% trans %}Sponsors{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='avantages') }}">{% trans %}Subscriber benefits{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}Help{% endtrans %}</span>
</details>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Help{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('core:page', page_name='FAQ') }}">{% trans %}FAQ{% endtrans %}</a></li>
<li><a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='Index') }}">{% trans %}Wiki{% endtrans %}</a></li>
</ul>
</div>
</details>
</div>
</nav>

View File

@ -10,10 +10,17 @@
{% block nav %}
{% endblock %}
{# if the template context has the `object_name` variable,
then this one will be used,
instead of the result of `str(object)` #}
{% if object and not object_name %}
{% set object_name=object %}
{% endif %}
{% block content %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form>
<form method="GET" action="javascript:history.back();">

View File

@ -29,14 +29,13 @@
<form method="post" action="{{ url('core:login') }}">
{% if form.errors %}
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
<br>
{% endif %}
{% csrf_token %}
<div>
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
<input id="id_username" maxlength="254" name="username" type="text" autofocus="autofocus" />
{{ form.username }}
{{ form.username.errors }}
</div>

View File

@ -86,19 +86,6 @@
{% trans %}Account number: {% endtrans %}{{ user.customer.account_id }}<br/>
{%- endmacro %}
{% macro show_slots(user) %}
{% if user.slots.filter(start_date__gte=timezone.now()).exists() %}
<h5>{% trans %}Slot{% endtrans %}</h5>
<ul>
{% for i in user.slots.filter(start_date__gte=timezone.now().replace(tzinfo=None)).all() %}
<li>{{ i.get_type_display() }} - {{i.machine.launderette }}, {{ i.start_date|date("l j") }} :
{{ i.start_date|time(DATETIME_FORMAT) }} |
<a href="{{ url('launderette:delete_slot', slot_id=i.id) }}">{% trans %}Delete{% endtrans %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}
{% macro show_tokens(user) %}
{% if user.tokens.exists() %}
<h5>{% trans %}Tokens{% endtrans %}</h5>

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