1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-07-01 13:31:16 +02:00

Compare commits

..

127 Commits

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

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

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

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

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

See merge request bde/nk20!202
2022-09-04 22:56:07 +02:00
7068170f18 fixing grammar in comments 2022-09-04 13:24:39 +02:00
45ee9a8941 Soge only payd WEI (not bde/kfet membership) 2022-09-04 12:52:40 +02:00
454ea19603 hide Soge during registration 2022-09-04 12:31:08 +02:00
5a77a66391 Merge branch 'beta' into 'main'
Friendships

See merge request bde/nk20!200
2022-04-13 12:45:06 +02:00
761fc170eb Update Spanish translation 2022-04-13 12:30:22 +02:00
ac23d7eb54 Generated translation files for de/es (but didn't translate anything) 2022-04-13 12:30:22 +02:00
40e7415062 Added translations for friendships 2022-04-13 12:30:22 +02:00
319405d2b1 Added a message to explain what frendships do
Signed-off-by: Nicolas Margulies <nicomarg@crans.org>
2022-04-13 12:30:22 +02:00
633ab88b04 Linting 2022-04-13 12:30:22 +02:00
e29b42eecc Add permissions related to trusting 2022-04-13 12:30:22 +02:00
dc69faaf1d Better user search to add friendships 2022-04-13 12:30:22 +02:00
442a5c5e36 First proro of trusting, with models and front, but no additional permissions 2022-04-13 12:30:22 +02:00
7ab0fec3bc Added trust model 2022-04-13 12:30:22 +02:00
bd4fb23351 Merge branch 'color_survi' into 'main'
switching to survivalist color

See merge request bde/nk20!199
2022-04-12 20:16:55 +02:00
ee22e9b3b6 fixing color to follow the proper theme 2022-04-12 18:33:22 +02:00
19ae616fb4 switching to survivalist color 2022-04-12 17:40:52 +02:00
b7657ec362 Merge branch 'color_ttlsp' into 'main'
Passage des couleur vers ttlsp

See merge request bde/nk20!197
2022-04-05 15:05:41 +02:00
4d03d9460d Passage des couleurs ttlsp 2022-04-05 14:45:41 +02:00
3633f66a87 Merge branch 'beta' into 'main'
Corrections de bugs

See merge request bde/nk20!195
2022-03-09 15:10:37 +01:00
d43fbe7ac6 Merge branch 'harden' into 'beta'
Harden Django project configuration

See merge request bde/nk20!194
2022-03-09 12:30:23 +01:00
df5f9b5f1e Harden Django project configuration
Set session and CSRF cookies as secure for production.
Set HSTS header to let browser remember HTTPS for 1 year.
2022-03-09 12:12:56 +01:00
4161248bff Add permissions to view/create/change/delete OAuth2 applications
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-03-09 12:06:19 +01:00
58136f3c48 Fix permission checks in the /api/me view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-03-09 11:45:24 +01:00
d9b4e0a9a9 Fix membership tables for clubs without an ending membership date
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-13 17:53:05 +01:00
8563a8d235 Fix membership tables for clubs without an ending membership date
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-13 17:51:22 +01:00
5f69232560 Merge branch 'beta' into 'main'
Optional scopes + small bug fix

See merge request bde/nk20!193
2022-02-12 14:37:58 +01:00
d3273e9ee2 Prepare WEI 2022 (because tests are broken)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-12 14:24:32 +01:00
4e30f805a7 Merge branch 'optional-scopes' into 'beta'
Implement optional scopes : clients can request scopes, but they are not guaranteed to get them

See merge request bde/nk20!192
2022-02-12 13:57:19 +01:00
546e422e64 Ensure some values exist before updating them
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-02-12 13:56:07 +01:00
9048a416df In the /api/me page, display note, profile and memberships only if we have associated permissions
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 23:25:18 +01:00
8578bd743c Add documentation about optional scopes
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 22:15:06 +01:00
45a10dad00 Refresh token expire between 14 days
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 22:00:08 +01:00
18a1282773 Implement optional scopes : clients can request scopes, but they are not guaranteed to get them
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-23 21:59:37 +01:00
132afc3d15 Fix scope view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-15 18:59:23 +01:00
6bf16a181a [ansible] Deploy buster-backports repository only on Debian 10
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-15 15:59:58 +01:00
e20df82346 Main branch is now called main
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-15 15:55:13 +01:00
1eb72044c2 Merge branch 'beta' into 'master'
Changements variés et mineurs

Closes #107 et #91

See merge request bde/nk20!191
2021-12-13 21:16:26 +01:00
f88eae924c Use local version of Turbolinks instead of using Cloudfare
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 21:00:34 +01:00
4b6e3ba546 Display club transactions only with note rights, fixes #107
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 20:01:00 +01:00
bf0fe3479f Merge branch 'lock-club-notes' into 'beta'
Verrouillage de notes

See merge request bde/nk20!190
2021-12-13 18:55:03 +01:00
45ba4f9537 Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 18:33:18 +01:00
b204805ce2 Add permissions to (un)lock club notes
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 18:31:36 +01:00
2f28e34cec Fix permissions to lock our own note
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 18:27:24 +01:00
9c8ea2cd41 Club notes can now be locked through web interface
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:48:20 +01:00
41289857b2 Merge branch 'tirage-au-sort' into 'beta'
Boutons

See merge request bde/nk20!189
2021-12-13 17:37:13 +01:00
28a8792c9f [activity] Add space before line breaks in Wiki export of activities
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:30:13 +01:00
58cafad032 Sort buttons by category name instead of id in button list
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:19:10 +01:00
7848cd9cc2 Don't search buttons by prefix
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:18:54 +01:00
d18ccfac23 Sort aliases by normalized name in profile alias view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-12-13 17:18:54 +01:00
e479e1e3a4 Added messages for Hide/Show 2021-10-07 23:06:40 +02:00
82b0c83b1f Added a Hide/Show button for transaction templates, fixes #91 2021-10-07 22:54:01 +02:00
38ca414ef6 Res[pot] can display user information in order to get first/last name in credits
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-06 10:44:24 +02:00
fd811053c7 Commit missing migrations
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-06 10:41:58 +02:00
9d386d1ecf Unauthenticated users can't display activity entry view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-06 10:41:42 +02:00
0bd447b608 Merge branch 'relax_requirements' into 'beta'
Relax requirements and ignore shell.nix

See merge request bde/nk20!187
2021-10-05 15:45:31 +02:00
3f3c93d928 Ignore shell.nix in Git tree
shell.nix is used in Nix to create a specific shell with custom
packages. The name is standardised and need to be in project folder to
ease development tools integrations.
2021-10-05 15:14:56 +02:00
340c90f5d3 Relax requirements
Relax requirements to allow the use of newer versions of dependencies
found in NixPkgs and ArchLinux. Do not limit upper version of
django-extensions as it is not mission critical.
2021-10-05 15:10:20 +02:00
ca2b9f061c Merge branch 'beta' into 'master'
Multiples fix, réparation des pots

Closes #75

See merge request bde/nk20!186
2021-10-05 12:02:03 +02:00
a05dfcbf3d Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-05 11:46:24 +02:00
ba3c0fb18d Fix activity get in invite view
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 21:53:35 +02:00
ab69963ea1 Merge branch 'cest-lheure-du-pot' into 'beta'
Améliorations Pot

See merge request bde/nk20!184
2021-10-04 18:45:21 +02:00
654c01631a BDE members can see aliases from other people now
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 18:29:34 +02:00
d94cc2a7ad NameNAN
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 18:26:14 +02:00
69bb38297f Fix membership dates for new memberships, fix tests
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 18:15:07 +02:00
9628560d64 Improve entry search with a debouncer
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 14:39:53 +02:00
df3bb71357 Serve static files with Nginx only in production to make JavaScript development easier
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:58:48 +02:00
2a216fd994 Entries are distinct
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:50:39 +02:00
8dd2619013 Activities are distinct
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:50:21 +02:00
62431a4910 Treasurers can manage activity entries
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-10-04 13:49:16 +02:00
946bc1e497 show that rows are clickable, fix #75 2021-10-01 14:35:29 +02:00
d4896bfd76 Check that club's note is active before creating an activity
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-28 17:03:32 +02:00
23f46cc598 Create transfers when pressing Enter in the amount part
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-28 16:57:23 +02:00
d1a9f21b56 Merge branch 'fix-pretty-money' into 'beta'
Pretty money function is invalid in Javascript: it mays display an additional euro

See merge request bde/nk20!183
2021-09-28 09:36:44 +00:00
d809b2595a Pretty money function is invalid in Javascript: it mays display an additional euro
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-28 11:20:57 +02:00
97803ac983 Merge branch 'beta' into 'master'
Le [Pot] c'est demain

See merge request bde/nk20!182
2021-09-27 14:52:09 +00:00
b951c4aa05 Merge branch 'fix-pot' into 'beta'
Entrées activités

See merge request bde/nk20!181
2021-09-27 14:37:10 +00:00
69b3d2ac9c [activity] Fix button shortcut to entries page
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 14:51:17 +02:00
f29054558a Fix note render with formattable aliases
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 14:30:47 +02:00
11dd8adbb7 Merge branch 'wei' into 'master'
[WEI] Algo de répartition

Closes #97 et #98

See merge request bde/nk20!180
2021-09-27 12:28:03 +00:00
d437f2bdbd Linting
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 13:59:43 +02:00
ac8453b04c [WEI] Reset cache after running algorithm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-27 13:56:10 +02:00
6b4d18f4b3 fix #97 2021-09-26 23:03:25 +02:00
668cfa71a7 fix #98 2021-09-26 23:02:31 +02:00
161db0b00b [WEI] Fix quotas
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 23:48:03 +02:00
8638c16b34 [WEI] New score function that takes in account scores given by other buses
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 22:15:45 +02:00
9583cec3ff [WEI] Fix quotas
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 21:10:23 +02:00
1ef25924a0 [WEI] Display status bar with tqdm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 20:46:34 +02:00
e89383e3f4 [WEI] Start repartition by non-male people
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 20:06:34 +02:00
79a116d9c6 [WEI] Cache optimization
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 20:05:20 +02:00
aa75ce5c7a [WEI] Don't manage hardcoded people in repartition algorithm
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 15:37:18 +02:00
a3a9dfc812 [Treasury] Don't add non-existing transactions to sogé-credits (eg. when membership is free)
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 11:00:10 +02:00
76531595ad 80 € for people that opened an account to Société générale and don't go to the WEI
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-09-16 10:58:23 +02:00
7e6a14296a Merge branch 'beta' into 'master'
Magnifique UI pour le WEI

See merge request bde/nk20!179
2021-09-13 18:06:03 +00:00
780f78b385 Merge branch 'wei' into 'beta'
[WEI] Belle UI pour attribuer les 1A dans les bus

See merge request bde/nk20!178
2021-09-13 17:50:34 +00:00
5828a20383 Merge branch 'beta' into 'master'
Corrections de bugs

See merge request bde/nk20!177
2021-09-09 12:00:01 +00:00
cea3138daf Merge branch 'wei' into 'beta'
Corrections de bugs

See merge request bde/nk20!176
2021-09-09 11:43:34 +00:00
b4ed354b73 Merge branch 'wei' into 'master'
Amélirations questionnaire WEI

See merge request bde/nk20!175
2021-09-05 17:32:57 +00:00
5eb3ffca66 Merge branch 'beta' into 'master'
OAuth2, tests WEI

See merge request bde/nk20!174
2021-09-02 20:49:58 +00:00
789ca149af Merge branch 'beta' into 'master'
WEI, diverses améliorations

See merge request bde/nk20!172
2021-08-29 13:22:04 +00:00
08ba0b263a Merge branch 'beta' into 'master'
changement couleur final (j'espère)

See merge request bde/nk20!166
2021-05-22 14:09:51 +00:00
4583958f50 Merge branch 'beta' into 'master'
Changement de couleurs

See merge request bde/nk20!165
2021-05-22 09:56:55 +00:00
bab394908d Merge branch 'beta' into 'master'
Bugs mineurs, documentation

See merge request bde/nk20!162
2021-04-23 19:32:54 +00:00
73 changed files with 3268 additions and 1385 deletions

1
.gitignore vendored
View File

@ -47,6 +47,7 @@ backups/
env/
venv/
db.sqlite3
shell.nix
# ansibles customs host
ansible/host_vars/*.yaml

View File

@ -1,8 +1,8 @@
# NoteKfet 2020
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt)
[![pipeline status](https://gitlab.crans.org/bde/nk20/badges/master/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
[![pipeline status](https://gitlab.crans.org/bde/nk20/badges/main/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/main/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
## Table des matières

View File

@ -7,7 +7,7 @@
prompt: "Password of the database (leave it blank to skip database init)"
private: yes
vars:
mirror: mirror.crans.org
mirror: eclats.crans.org
roles:
- 1-apt-basic
- 2-nk20

View File

@ -1,6 +0,0 @@
---
note:
server_name: note-beta.crans.org
git_branch: beta
cron_enabled: false
email: notekfet2020@lists.crans.org

View File

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

View File

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

View File

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

View File

@ -1,14 +1,15 @@
---
- name: Add buster-backports to apt sources
- name: Add buster-backports to apt sources if needed
apt_repository:
repo: deb http://{{ mirror }}/debian buster-backports main
state: present
when: ansible_facts['distribution'] == "Debian"
when:
- ansible_distribution == "Debian"
- ansible_distribution_major_version | int == 10
- name: Install note_kfet APT dependencies
apt:
update_cache: true
default_release: "{{ 'buster-backports' if ansible_facts['distribution'] == 'Debian' }}"
install_recommends: false
name:
# Common tools

View File

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

View File

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

View File

@ -28,6 +28,12 @@ class ActivityForm(forms.ModelForm):
shuffle(clubs)
self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
def clean_organizer(self):
organizer = self.cleaned_data['organizer']
if not organizer.note.is_active:
self.add_error('organiser', _('The note of this club is inactive.'))
return organizer
def clean_date_end(self):
date_end = self.cleaned_data["date_end"]
date_start = self.cleaned_data["date_start"]

View File

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

View File

@ -63,7 +63,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
refreshBalance();
}
alias_obj.keyup(reloadTable);
alias_obj.keyup(function(event) {
let code = event.originalEvent.keyCode
if (65 <= code <= 122 || code === 13) {
debounce(reloadTable)()
}
});
$(document).ready(init);

View File

@ -66,8 +66,8 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
ordering = ('-date_start',)
extra_context = {"title": _("Activities")}
def get_queryset(self):
return super().get_queryset().distinct()
def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -78,9 +78,7 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
prefix='upcoming-',
)
started_activities = Activity.objects\
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.filter(open=True, valid=True).all()
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
context["started_activities"] = started_activities
return context
@ -145,7 +143,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.get(pk=self.kwargs["pk"])
.filter(pk=self.kwargs["pk"]).first()
form.fields["inviter"].initial = self.request.user.note
return form
@ -170,6 +168,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
it is closed or doesn't manage entries.
"""
if not self.request.user.is_authenticated:
return self.handle_no_permission()
activity = Activity.objects.get(pk=self.kwargs["pk"])
sample_entry = Entry(activity=activity, note=self.request.user.note)
@ -192,7 +193,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
.filter(activity=activity)\
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
.order_by('last_name', 'first_name').distinct()
.order_by('last_name', 'first_name')
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
@ -206,7 +207,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
)
else:
guest_qs = guest_qs.none()
return guest_qs
return guest_qs.distinct()
def get_invited_note(self, activity):
"""

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

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

View File

@ -7,8 +7,11 @@ from django.contrib.auth.models import User
from django.utils import timezone
from rest_framework import serializers
from member.api.serializers import ProfileSerializer, MembershipSerializer
from member.models import Membership
from note.api.serializers import NoteSerializer
from note.models import Alias
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
class UserSerializer(serializers.ModelSerializer):
@ -45,18 +48,30 @@ class OAuthSerializer(serializers.ModelSerializer):
"""
normalized_name = serializers.SerializerMethodField()
profile = ProfileSerializer()
profile = serializers.SerializerMethodField()
note = NoteSerializer()
note = serializers.SerializerMethodField()
memberships = serializers.SerializerMethodField()
def get_normalized_name(self, obj):
return Alias.normalize(obj.username)
def get_profile(self, obj):
# Display the profile of the user only if we have rights to see it.
return ProfileSerializer().to_representation(obj.profile) \
if PermissionBackend.check_perm(get_current_request(), 'member.view_profile', obj.profile) else None
def get_note(self, obj):
# Display the note of the user only if we have rights to see it.
return NoteSerializer().to_representation(obj.note) \
if PermissionBackend.check_perm(get_current_request(), 'note.view_note', obj.note) else None
def get_memberships(self, obj):
# Display only memberships that we are allowed to see.
return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now()))
obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())
.filter(PermissionBackend.filter_queryset(get_current_request(), Membership, 'view')))
class Meta:
model = User

View File

@ -19,8 +19,8 @@ def create_bde_and_kfet(apps, schema_editor):
membership_fee_paid=500,
membership_fee_unpaid=500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
membership_start="2021-08-01",
membership_end="2022-09-30",
)
Club.objects.get_or_create(
id=2,
@ -31,8 +31,8 @@ def create_bde_and_kfet(apps, schema_editor):
membership_fee_paid=3500,
membership_fee_unpaid=3500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
membership_start="2021-08-01",
membership_end="2022-09-30",
)
NoteClub.objects.get_or_create(

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.24 on 2021-10-05 13:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0007_auto_20210313_1235'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='department',
field=models.CharField(choices=[('A0', 'Informatics (A0)'), ('A1', 'Mathematics (A1)'), ('A2', 'Physics (A2)'), ("A'2", "Applied physics (A'2)"), ("A''2", "Chemistry (A''2)"), ('A3', 'Biology (A3)'), ('B1234', 'SAPHIRE (B1234)'), ('B1', 'Mechanics (B1)'), ('B2', 'Civil engineering (B2)'), ('B3', 'Mechanical engineering (B3)'), ('B4', 'EEA (B4)'), ('C', 'Design (C)'), ('D2', 'Economy-management (D2)'), ('D3', 'Social sciences (D3)'), ('E', 'English (E)'), ('EXT', 'External (EXT)')], max_length=8, verbose_name='department'),
),
]

View File

@ -258,16 +258,18 @@ class Club(models.Model):
This function is called each time the club detail view is displayed.
Update the year of the membership dates.
"""
if not self.membership_start:
if not self.membership_start or not self.membership_end:
return
today = datetime.date.today()
if (today - self.membership_start).days >= 365:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
if self.membership_end:
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
self._force_save = True
self.save(force_update=True)
@ -413,6 +415,12 @@ class Membership(models.Model):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
# Ensure that club membership dates are valid
old_membership_start = self.club.membership_start
self.club.update_membership_dates()
if self.club.membership_start != old_membership_start:
self.club.save()
created = not self.pk
if not created:
for role in self.roles.all():

View File

@ -0,0 +1,53 @@
/**
* On form submit, create a new friendship
*/
function create_trust (e) {
// Do not submit HTML form
e.preventDefault()
// Get data and send to API
const formData = new FormData(e.target)
$.getJSON('/api/note/alias/'+formData.get('trusted') + '/',
function (trusted_alias) {
if ((trusted_alias.note == formData.get('trusting')))
{
addMsg(gettext("You can't add yourself as a friend"), "danger")
return
}
$.post('/api/note/trust/', {
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
trusting: formData.get('trusting'),
trusted: trusted_alias.note
}).done(function () {
// Reload table
$('#trust_table').load(location.pathname + ' #trust_table')
addMsg(gettext('Friendship successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the alias
* @param button_id:Integer Alias id to remove
*/
function delete_button (button_id) {
$.ajax({
url: '/api/note/trust/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
addMsg(gettext('Friendship successfully deleted'), 'success')
$('#trust_table').load(location.pathname + ' #trust_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
$(document).ready(function () {
// Attach event
document.getElementById('form_trust').addEventListener('submit', create_trust)
})

View File

@ -31,7 +31,8 @@ class ClubTable(tables.Table):
row_attrs = {
'class': 'table-row',
'id': lambda record: "row-" + str(record.pk),
'data-href': lambda record: record.pk
'data-href': lambda record: record.pk,
'style': 'cursor:pointer',
}
@ -74,7 +75,8 @@ class UserTable(tables.Table):
model = User
row_attrs = {
'class': 'table-row',
'data-href': lambda record: record.pk
'data-href': lambda record: record.pk,
'style': 'cursor:pointer',
}
@ -118,7 +120,7 @@ class MembershipTable(tables.Table):
club=record.club,
user=record.user,
date_start__gte=record.club.membership_start,
date_end__lte=record.club.membership_end,
date_end__lte=record.club.membership_end or date(9999, 12, 31),
).exists(): # If the renew is not yet performed
empty_membership = Membership(
club=record.club,

View File

@ -25,6 +25,14 @@
</a>
</dd>
<dt class="col-xl-6">{% trans 'friendships'|capfirst %}</dt>
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:user_trust' user_object.pk %}">
<i class="fa fa-edit"></i>
{% trans 'Manage friendships' %} ({{ user_object.note.trusting.all|length }})
</a>
</dd>
{% if "member.view_profile"|has_perm:user_object.profile %}
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.profile.section }}</dd>

View File

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

View File

@ -23,5 +23,6 @@ urlpatterns = [
path('user/<int:pk>/update/', views.UserUpdateView.as_view(), name="user_update_profile"),
path('user/<int:pk>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
path('user/<int:pk>/trust', views.ProfileTrustView.as_view(), name="user_trust"),
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'),
]

View File

@ -8,6 +8,7 @@ from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Q, F
from django.shortcuts import redirect
@ -18,9 +19,9 @@ from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin
from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token
from note.models import Alias, NoteUser
from note.models import Alias, NoteClub, NoteUser, Trust
from note.models.transactions import Transaction, SpecialTransaction
from note.tables import HistoryTable, AliasTable
from note.tables import HistoryTable, AliasTable, TrustTable
from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend
from permission.models import Role
@ -174,7 +175,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
modified_note = NoteUser.objects.get(pk=user.note.pk)
# Don't log these tests
modified_note._no_signal = True
modified_note.is_active = True
modified_note.is_active = False
modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = user.note.is_active and PermissionBackend\
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
@ -183,14 +184,14 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
modified_note._force_save = True
modified_note.save()
context["can_force_lock"] = user.note.is_active and PermissionBackend\
.check_perm(self.request, "note.change_note_is_active", modified_note)
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
old_note._force_save = True
old_note._no_signal = True
old_note.save()
modified_note.refresh_from_db()
modified_note.is_active = True
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request, "note.change_note_is_active", modified_note)
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
return context
@ -243,6 +244,39 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
return context
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
View and manage user trust relationships
"""
model = User
template_name = 'member/profile_trust.html'
context_object_name = 'user_object'
extra_context = {"title": _("Note friendships")}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["trusting"] = TrustTable(
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
trusting=context["object"].note,
trusted=context["object"].note
))
context["widget"] = {
"name": "trusted",
"attrs": {
"model_pk": ContentType.objects.get_for_model(Alias).pk,
"class": "autocomplete form-control",
"id": "trusted",
"resetable": True,
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
"name_field": "name",
"placeholder": ""
}
}
return context
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
View and manage user aliases.
@ -256,7 +290,8 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context = super().get_context_data(**kwargs)
note = context['object'].note
context["aliases"] = AliasTable(
note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
.order_by('normalized_name').all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note,
name="",
@ -403,9 +438,12 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
context = super().get_context_data(**kwargs)
club = context["club"]
club = self.object
context["note"] = club.note
if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
club.update_membership_dates()
# managers list
managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club",
date_start__lte=date.today(), date_end__gte=date.today())\
@ -443,6 +481,29 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "member.add_membership", empty_membership)
# Check permissions to see if the authenticated user can lock/unlock the note
with transaction.atomic():
modified_note = NoteClub.objects.get(pk=club.note.pk)
# Don't log these tests
modified_note._no_signal = True
modified_note.is_active = False
modified_note.inactivity_reason = 'manual'
context["can_lock_note"] = club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
old_note = NoteClub.objects.select_for_update().get(pk=club.note.pk)
modified_note.inactivity_reason = 'forced'
modified_note._force_save = True
modified_note.save()
context["can_force_lock"] = club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
old_note._force_save = True
old_note._no_signal = True
old_note.save()
modified_note.refresh_from_db()
modified_note.is_active = True
context["can_unlock_note"] = not club.note.is_active and PermissionBackend \
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
return context

View File

@ -12,7 +12,7 @@ from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from rest_framework.utils import model_meta
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias, Trust
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
RecurrentTransaction, SpecialTransaction
@ -77,6 +77,22 @@ class NoteUserSerializer(serializers.ModelSerializer):
return str(obj)
class TrustSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Trusts.
The djangorestframework plugin will analyse the model `Trust` and parse all fields in the API.
"""
class Meta:
model = Trust
fields = '__all__'
def validate(self, attrs):
instance = Trust(**attrs)
instance.clean()
return attrs
class AliasSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Aliases.

View File

@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import NotePolymorphicViewSet, AliasViewSet, ConsumerViewSet, \
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet, \
TrustViewSet
def register_note_urls(router, path):
@ -11,6 +12,7 @@ def register_note_urls(router, path):
"""
router.register(path + '/note', NotePolymorphicViewSet)
router.register(path + '/alias', AliasViewSet)
router.register(path + '/trust', TrustViewSet)
router.register(path + '/consumer', ConsumerViewSet)
router.register(path + '/transaction/category', TemplateCategoryViewSet)

View File

@ -14,8 +14,9 @@ from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSe
from permission.backends import PermissionBackend
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer, \
TrustSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial, Trust
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
@ -56,11 +57,41 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
return queryset.order_by("id")
class TrustViewSet(ReadProtectedModelViewSet):
"""
REST Trust View set.
The djangorestframework plugin will get all `Trust` objects, serialize it to JSON with the given serializer,
then render it on /api/note/trust/
"""
queryset = Trust.objects
serializer_class = TrustSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
'$trusted__alias__name', '$trusted__alias__normalized_name']
filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user']
ordering_fields = ['trusting', 'trusted', ]
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']:
# trust relationship can't change people involved
serializer_class.Meta.read_only_fields = ('trusting', 'trusting',)
return serializer_class
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
try:
self.perform_destroy(instance)
except ValidationError as e:
return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
class AliasViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
then render it on /api/aliases/
then render it on /api/note/aliases/
"""
queryset = Alias.objects
serializer_class = AliasSerializer

View File

@ -0,0 +1,27 @@
# Generated by Django 2.2.24 on 2021-09-05 19:16
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0005_auto_20210313_1235'),
]
operations = [
migrations.CreateModel(
name='Trust',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('trusted', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusted', to='note.Note', verbose_name='trusted')),
('trusting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusting', to='note.Note', verbose_name='trusting')),
],
options={
'verbose_name': 'frienship',
'verbose_name_plural': 'friendships',
'unique_together': {('trusting', 'trusted')},
},
),
]

View File

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

View File

@ -217,6 +217,38 @@ class NoteSpecial(Note):
return self.special_type
class Trust(models.Model):
"""
A one-sided trust relationship bertween two users
If another user considers you as your friend, you can transfer money from
them
"""
trusting = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='trusting',
verbose_name=_('trusting')
)
trusted = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='trusted',
verbose_name=_('trusted')
)
class Meta:
verbose_name = _("frienship")
verbose_name_plural = _("friendships")
unique_together = ("trusting", "trusted")
def __str__(self):
return _("Friendship between {trusting} and {trusted}").format(
trusting=str(self.trusting), trusted=str(self.trusted))
class Alias(models.Model):
"""
points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance.

View File

@ -221,7 +221,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
.done(function () {
if (!isNaN(source.balance)) {
const newBalance = source.balance - quantity * amount
if (newBalance <= -5000) {
if (newBalance <= -2000) {
addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'but the emitter note %s is very negative.'), [source_alias, source_alias]), 'danger', 30000)
} else if (newBalance < 0) {

View File

@ -222,6 +222,13 @@ $(document).ready(function () {
})
})
// Make transfer when pressing Enter on the amount section
$('#amount, #reason, #last_name, #first_name, #bank').keypress((event) => {
if (event.originalEvent.charCode === 13) {
$('#btn_transfer').click()
}
})
$('#btn_transfer').click(function () {
if (LOCK) { return }
@ -307,7 +314,7 @@ $('#btn_transfer').click(function () {
if (!isNaN(source.note.balance)) {
const newBalance = source.note.balance - source.quantity * dest.quantity * amount
if (newBalance <= -5000) {
if (newBalance <= -2000) {
addMsg(interpolate(gettext('Warning, the transaction of %s from the note %s to the note %s succeed, but the emitter note %s is very negative.'),
[pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, source.name]), 'danger', 10000)
reset()
@ -348,14 +355,14 @@ $('#btn_transfer').click(function () {
destination_alias: dest.name
}).done(function () {
addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
[pretty_money(source.quantity * dest.quantity * amount), source.name, + dest.name, gettext('insufficient funds')]), 'danger', 10000)
[pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, gettext('insufficient funds')]), 'danger', 10000)
reset()
}).fail(function (err) {
const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText }
addMsg(interpolate(gettext('Transfer of %s from %s to %s failed: %s'),
[pretty_money(source.quantity * dest.quantity * amount), source.name, + dest.name, error]), 'danger')
[pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, error]), 'danger')
LOCK = false
})
})

View File

@ -4,13 +4,13 @@
import html
import django_tables2 as tables
from django.utils.html import format_html
from django.utils.html import format_html, mark_safe
from django_tables2.utils import A
from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from .models.notes import Alias
from .models.notes import Alias, Trust
from .models.transactions import Transaction, TransactionTemplate
from .templatetags.pretty_money import pretty_money
@ -148,6 +148,31 @@ DELETE_TEMPLATE = """
"""
class TrustTable(tables.Table):
class Meta:
attrs = {
'class': 'table table condensed table-striped',
'id': "trust_table"
}
model = Trust
fields = ("trusted",)
template_name = 'django_tables2/bootstrap4.html'
show_header = False
trusted = tables.Column(attrs={'td': {'class': 'text_center'}})
delete_col = tables.TemplateColumn(
template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
attrs={
'td': {
'class': lambda record: 'col-sm-1'
+ (' d-none' if not PermissionBackend.check_perm(
get_current_request(), "note.delete_trust", record)
else '')}},
verbose_name=_("Delete"),)
class AliasTable(tables.Table):
class Meta:
attrs = {
@ -197,6 +222,17 @@ class ButtonTable(tables.Table):
verbose_name=_("Edit"),
)
hideshow = tables.Column(
verbose_name=_("Hide/Show"),
accessor="pk",
attrs={
'td': {
'class': 'col-sm-1',
'id': lambda record: "hideshow_" + str(record.pk),
}
},
)
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}},
@ -204,3 +240,16 @@ class ButtonTable(tables.Table):
def render_amount(self, value):
return pretty_money(value)
def order_category(self, queryset, is_descending):
return queryset.order_by(f"{'-' if is_descending else ''}category__name"), True
def render_hideshow(self, record):
val = '<button id="'
val += str(record.pk)
val += '" class="btn btn-secondary btn-sm" \
onclick="hideshow(' + str(record.id) + ',' + \
str(record.display).lower() + ')">'
val += str(_("Hide/Show"))
val += '</button>'
return mark_safe(val)

View File

@ -10,21 +10,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
{# bandeau transfert/crédit/débit/activité #}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle btn-block" data-toggle="buttons">
<label for="type_transfer" class="btn btn-sm btn-outline-primary active">
<input type="radio" name="transaction_type" id="type_transfer">
{% trans "Transfer" %}
</label>
{% if "note.notespecial"|not_empty_model_list %}
<label for="type_credit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_credit">
{% trans "Credit" %}
<div class="btn-group btn-block">
<div class="btn-group btn-group-toggle btn-block" data-toggle="buttons">
<label for="type_transfer" class="btn btn-sm btn-outline-primary active">
<input type="radio" name="transaction_type" id="type_transfer">
{% trans "Transfer" %}
</label>
<label for="type_debit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_debit">
{% trans "Debit" %}
</label>
{% endif %}
{% if "note.notespecial"|not_empty_model_list %}
<label for="type_credit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_credit">
{% trans "Credit" %}
</label>
<label for="type_debit" class="btn btn-sm btn-outline-primary">
<input type="radio" name="transaction_type" id="type_debit">
{% trans "Debit" %}
</label>
{% endif %}
</div>
{# Add shortcuts for opened activites if necessary #}
{% for activity in activities_open %}
<a href="{% url "activity:activity_entry" pk=activity.pk %}" class="btn btn-sm btn-outline-primary">
{% trans "Entries" %} {{ activity.name }}

View File

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

View File

@ -53,7 +53,7 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
# Add a shortcut for entry page for open activities
if "activity" in settings.INSTALLED_APPS:
from activity.models import Activity
activities_open = Activity.objects.filter(open=True).filter(
activities_open = Activity.objects.filter(open=True, activity_type__manage_entries=True).filter(
PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
context["activities_open"] = [a for a in activities_open
if PermissionBackend.check_perm(self.request,
@ -90,9 +90,9 @@ class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, Sing
if "search" in self.request.GET:
pattern = self.request.GET["search"]
qs = qs.filter(
Q(name__iregex="^" + pattern)
| Q(destination__club__name__iregex="^" + pattern)
| Q(category__name__iregex="^" + pattern)
Q(name__iregex=pattern)
| Q(destination__club__name__iregex=pattern)
| Q(category__name__iregex=pattern)
| Q(description__iregex=pattern)
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.28 on 2023-07-24 10:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('permission', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='role',
name='for_club',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='for club'),
),
]

View File

@ -339,6 +339,7 @@ class Role(models.Model):
"member.Club",
verbose_name=_("for club"),
on_delete=models.PROTECT,
blank=True,
null=True,
default=None,
)

View File

@ -1,6 +1,6 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from oauth2_provider.oauth2_validators import OAuth2Validator
from oauth2_provider.scopes import BaseScopes
from member.models import Club
from note_kfet.middlewares import get_current_request
@ -32,3 +32,26 @@ class PermissionScopes(BaseScopes):
return []
return [f"{p.id}_{p.membership.club.id}"
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
class PermissionOAuth2Validator(OAuth2Validator):
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
"""
User can request as many scope as he wants, including invalid scopes,
but it will have only the permissions he has.
This allows clients to request more permission to get finally a
subset of permissions.
"""
valid_scopes = set()
for t in Permission.PERMISSION_TYPES:
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0]):
scope = f"{p.id}_{p.membership.club.id}"
if scope in scopes:
valid_scopes.add(scope)
request.scopes = valid_scopes
return valid_scopes

View File

@ -11,25 +11,25 @@
<div class="accordion" id="accordionApps">
{% for app, app_scopes in scopes.items %}
<div class="card">
<div class="card-header" id="app-{{ app.name.lower }}-title">
<div class="card-header" id="app-{{ app.name|slugify }}-title">
<a class="text-decoration-none collapsed" href="#" data-toggle="collapse"
data-target="#app-{{ app.name.lower }}" aria-expanded="false"
aria-controls="app-{{ app.name.lower }}">
data-target="#app-{{ app.name|slugify }}" aria-expanded="false"
aria-controls="app-{{ app.name|slugify }}">
{{ app.name }}
</a>
</div>
<div class="collapse" id="app-{{ app.name.lower }}" aria-labelledby="app-{{ app.name.lower }}" data-target="#accordionApps">
<div class="collapse" id="app-{{ app.name|slugify }}" aria-labelledby="app-{{ app.name|slugify }}" data-target="#accordionApps">
<div class="card-body">
{% for scope_id, scope_desc in app_scopes.items %}
<div class="form-group">
<label class="form-check-label" for="scope-{{ app.name.lower }}-{{ scope_id }}">
<input type="checkbox" id="scope-{{ app.name.lower }}-{{ scope_id }}"
name="scope-{{ app.name.lower }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
<label class="form-check-label" for="scope-{{ app.name|slugify }}-{{ scope_id }}">
<input type="checkbox" id="scope-{{ app.name|slugify }}-{{ scope_id }}"
name="scope-{{ app.name|slugify }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
{{ scope_desc }}
</label>
</div>
{% endfor %}
<p id="url-{{ app.name.lower }}">
<p id="url-{{ app.name|slugify }}">
<a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code" target="_blank">
{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code
</a>
@ -51,11 +51,10 @@
{% block extrajavascript %}
<script>
{% for app in scopes.keys %}
let elements = document.getElementsByName("scope-{{ app.name.lower }}");
for (let element of elements) {
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
element.onchange = function (event) {
let scope = ""
for (let element of elements) {
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
if (element.checked) {
scope += element.value + " "
}
@ -63,7 +62,7 @@
scope = scope.substr(0, scope.length - 1)
document.getElementById("url-{{ app.name.lower }}").innerHTML = 'Scopes : ' + scope
document.getElementById("url-{{ app.name|slugify }}").innerHTML = 'Scopes : ' + scope
+ '<br><a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='+ scope.replaceAll(' ', '%20')
+ '" target="_blank">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='
+ scope.replaceAll(' ', '%20') + '</a>'

View File

@ -46,8 +46,8 @@ class SignUpForm(UserCreationForm):
class DeclareSogeAccountOpenedForm(forms.Form):
soge_account = forms.BooleanField(
label=_("I declare that I opened or I will open soon a bank account in the Société générale with the BDE \
partnership."),
label=_("I declare that I opened or I will open soon a bank account in the Société générale with the BDE "
"partnership."),
help_text=_("Warning: this engages you to open your bank account. If you finally decides to don't open your "
"account, you will have to pay the BDE membership."),
required=False,

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.24 on 2021-10-05 13:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('treasury', '0003_auto_20210321_1034'),
]
operations = [
migrations.AlterField(
model_name='sogecredit',
name='transactions',
field=models.ManyToManyField(blank=True, related_name='_sogecredit_transactions_+', to='note.MembershipTransaction', verbose_name='membership transactions'),
),
]

View File

@ -1,6 +1,6 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from datetime import date
from django.conf import settings
@ -305,8 +305,16 @@ class SogeCredit(models.Model):
@property
def amount(self):
return self.credit_transaction.total if self.valid \
else sum(transaction.total for transaction in self.transactions.all())
if self.valid:
return self.credit_transaction.total
amount = sum(transaction.total for transaction in self.transactions.all())
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIMembership
if not WEIMembership.objects\
.filter(club__weiclub__year=self.credit_transaction.created_at.year, user=self.user).exists():
# 80 € for people that don't go to WEI
amount += 8000
return amount
def update_transactions(self):
"""
@ -321,15 +329,18 @@ class SogeCredit(models.Model):
bde_qs = Membership.objects.filter(user=self.user, club=bde, date_start__gte=bde.membership_start)
kfet_qs = Membership.objects.filter(user=self.user, club=kfet, date_start__gte=kfet.membership_start)
if bde_qs.exists():
m = bde_qs.get()
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
if kfet_qs.exists():
m = kfet_qs.get()
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
## Soge do not pay BDE and kfet memberships this year (2022-2023)
# if bde_qs.exists():
# m = bde_qs.get()
# if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
# if m.transaction not in self.transactions.all():
# self.transactions.add(m.transaction)
#
# if kfet_qs.exists():
# m = kfet_qs.get()
# if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
# if m.transaction not in self.transactions.all():
# self.transactions.add(m.transaction)
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIClub
@ -337,8 +348,9 @@ class SogeCredit(models.Model):
wei_qs = Membership.objects.filter(user=self.user, club=wei, date_start__gte=wei.membership_start)
if wei_qs.exists():
m = wei_qs.get()
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
for tr in self.transactions.all():
tr.valid = False

View File

@ -108,7 +108,7 @@ class InvoiceListView(LoginRequiredMixin, SingleTableView):
name="",
address="",
)
if not PermissionBackend.check_perm(self.request, "treasury.add_invoice", sample_invoice):
if not PermissionBackend.check_perm(self.request, "treasury.view_invoice", sample_invoice):
raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs)

View File

@ -2,11 +2,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
from .wei2021 import WEISurvey2021
from .wei2022 import WEISurvey2022
__all__ = [
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
]
CurrentSurvey = WEISurvey2021
CurrentSurvey = WEISurvey2022

View File

@ -50,15 +50,19 @@ class WEIBusInformation:
self.bus.information = d
self.bus.save()
def free_seats(self, surveys: List["WEISurvey"] = None):
size = self.bus.size
already_occupied = WEIMembership.objects.filter(bus=self.bus).count()
def free_seats(self, surveys: List["WEISurvey"] = None, quotas=None):
if not quotas:
size = self.bus.size
already_occupied = WEIMembership.objects.filter(bus=self.bus).count()
quotas = {self.bus: size - already_occupied}
quota = quotas[self.bus]
valid_surveys = sum(1 for survey in surveys if survey.information.valid
and survey.information.get_selected_bus() == self.bus) if surveys else 0
return size - already_occupied - valid_surveys
return quota - valid_surveys
def has_free_seats(self, surveys=None):
return self.free_seats(surveys) > 0
def has_free_seats(self, surveys=None, quotas=None):
return self.free_seats(surveys, quotas) > 0
class WEISurveyAlgorithm:
@ -86,14 +90,20 @@ class WEISurveyAlgorithm:
"""
Queryset of all first year registrations
"""
return WEIRegistration.objects.filter(wei__year=cls.get_survey_class().get_year(), first_year=True)
if not hasattr(cls, '_registrations'):
cls._registrations = WEIRegistration.objects.filter(wei__year=cls.get_survey_class().get_year(),
first_year=True).all()
return cls._registrations
@classmethod
def get_buses(cls) -> QuerySet:
"""
Queryset of all buses of the associated wei.
"""
return Bus.objects.filter(wei__year=cls.get_survey_class().get_year(), size__gt=0)
if not hasattr(cls, '_buses'):
cls._buses = Bus.objects.filter(wei__year=cls.get_survey_class().get_year(), size__gt=0).all()
return cls._buses
@classmethod
def get_bus_information(cls, bus):
@ -135,7 +145,10 @@ class WEISurvey:
"""
The WEI associated to this kind of survey.
"""
return WEIClub.objects.get(year=cls.get_year())
if not hasattr(cls, '_wei'):
cls._wei = WEIClub.objects.get(year=cls.get_year())
return cls._wei
@classmethod
def get_survey_information_class(cls):
@ -210,3 +223,15 @@ class WEISurvey:
self.information.selected_bus_pk = None
self.information.selected_bus_name = None
self.information.valid = False
@classmethod
def clear_cache(cls):
"""
Clear stored information.
"""
if hasattr(cls, '_wei'):
del cls._wei
if hasattr(cls.get_algorithm_class(), '_registrations'):
del cls.get_algorithm_class()._registrations
if hasattr(cls.get_algorithm_class(), '_buses'):
del cls.get_algorithm_class()._buses

View File

@ -1,13 +1,17 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import time
from functools import lru_cache
from random import Random
from django import forms
from django.db import transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import WEIMembership
WORDS = [
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
@ -135,20 +139,41 @@ class WEISurvey2021(WEISurvey):
"""
return self.information.step == 20
@classmethod
@lru_cache()
def word_mean(cls, word):
"""
Calculate the mid-score given by all buses.
"""
buses = cls.get_algorithm_class().get_buses()
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
@lru_cache()
def score(self, bus):
if not self.is_complete():
raise ValueError("Survey is not ended, can't calculate score")
bus_info = self.get_algorithm_class().get_bus_information(bus)
return sum(bus_info.scores[getattr(self.information, 'word' + str(i))] for i in range(1, 21)) / 20
bus_info = self.get_algorithm_class().get_bus_information(bus)
# Score is the given score by the bus subtracted to the mid-score of the buses.
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
return s
@lru_cache()
def scores_per_bus(self):
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
@lru_cache()
def ordered_buses(self):
values = list(self.scores_per_bus().items())
values.sort(key=lambda item: -item[1])
return values
@classmethod
def clear_cache(cls):
cls.word_mean.cache_clear()
return super().clear_cache()
class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
"""
@ -164,19 +189,72 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
def get_bus_information_class(cls):
return WEIBusInformation2021
def run_algorithm(self):
def run_algorithm(self, display_tqdm=False):
"""
Gale-Shapley algorithm implementation.
We modify it to allow buses to have multiple "weddings".
"""
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
surveys = [s for s in surveys if s.is_complete()]
free_surveys = [s for s in surveys if not s.information.valid] # Remaining surveys
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
# Don't manage hardcoded people
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
# Reset previous algorithm run
for survey in surveys:
survey.free()
survey.save()
non_men = [s for s in surveys if s.registration.gender != 'male']
men = [s for s in surveys if s.registration.gender == 'male']
quotas = {}
registrations = self.get_registrations()
non_men_total = registrations.filter(~Q(gender='male')).count()
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
tqdm_obj = None
if display_tqdm:
from tqdm import tqdm
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
# Repartition for non men people first
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
quotas = {}
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = free_seats
if display_tqdm:
tqdm_obj.close()
from tqdm import tqdm
tqdm_obj = tqdm(total=len(men), desc="Hommes")
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
if display_tqdm:
tqdm_obj.close()
# Clear cache information after running algorithm
WEISurvey2021.clear_cache()
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
free_surveys = surveys.copy() # Remaining surveys
while free_surveys: # Some students are not affected
survey = free_surveys[0]
buses = survey.ordered_buses() # Preferences of the student
for bus, _ignored in buses:
if self.get_bus_information(bus).has_free_seats(surveys):
for bus, current_score in buses:
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
# Selected bus has free places. Put student in the bus
survey.select_bus(bus)
survey.save()
@ -184,7 +262,6 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
break
else:
# Current bus has not enough places. Remove the least preferred student from the bus if existing
current_score = survey.score(bus)
least_preferred_survey = None
least_score = -1
# Find the least student in the bus that has a lower score than the current student
@ -206,6 +283,11 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
free_surveys.append(least_preferred_survey)
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
raise ValueError(f"User {survey.registration.user} has no free seat")
if tqdm_obj is not None:
tqdm_obj.n = len(surveys) - len(free_surveys)
tqdm_obj.refresh()

View File

@ -0,0 +1,296 @@
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import time
from functools import lru_cache
from random import Random
from django import forms
from django.db import transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import WEIMembership
WORDS = [
'ABBA', 'After', 'Alcoolique anonyme', 'Ambiance festive', 'Années 2000', 'Apéro', 'Art',
'Baby foot billard biere pong', 'BBQ', 'Before', 'Bière pong', 'Bon enfant', 'Calme', 'Canapé',
'Chanson paillarde', 'Chanson populaire', 'Chartreuse', 'Cheerleader', 'Chill', 'Choré',
'Cinéma', 'Cocktail', 'Comédie musicle', 'Commercial', 'Copaing', 'Danse', 'Dancefloor',
'Electro', 'Fanfare', 'Gin tonic', 'Inclusif', 'Jazz', "Jeux d'alcool", 'Jeux de carte',
'Jeux de rôle', 'Jeux de société', 'JUL', 'Jus de fruit', 'Kfet', 'Kleptomanie assurée',
'LGBTQ+', 'Livre', 'Morning beer', 'Musique', 'NAPS', 'Paillettes', 'Pastis', 'Paté Hénaff',
'Peluche', 'Pena baiona', "Peu d'alcool", 'Pilier de bar', 'PMU', 'Poulpe', 'Punch', 'Rap',
'Réveil', 'Rock', 'Rugby', 'Sandwich', 'Serge', 'Shot', 'Sociable', 'Spectacle', 'Techno',
'Techno house', 'Thérapie Taxi', 'Tradition kchanaises', 'Troisième mi-temps', 'Turn up',
'Vodka', 'Vodka pomme', 'Volley', 'Vomi stratégique'
]
class WEISurveyForm2022(forms.Form):
"""
Survey form for the year 2022.
Members choose 20 words, from which we calculate the best associated bus.
"""
word = forms.ChoiceField(
label=_("Choose a word:"),
widget=forms.RadioSelect(),
)
def set_registration(self, registration):
"""
Filter the bus selector with the buses of the current WEI.
"""
information = WEISurveyInformation2022(registration)
if not information.seed:
information.seed = int(1000 * time.time())
information.save(registration)
registration._force_save = True
registration.save()
if self.data:
self.fields["word"].choices = [(w, w) for w in WORDS]
if self.is_valid():
return
rng = Random((information.step + 1) * information.seed)
words = None
buses = WEISurveyAlgorithm2022.get_buses()
informations = {bus: WEIBusInformation2022(bus) for bus in buses}
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
average_score = sum(scores) / len(scores)
preferred_words = {bus: [word for word in WORDS
if informations[bus].scores[word] >= average_score]
for bus in buses}
while words is None or len(set(words)) != len(words):
# Ensure that there is no the same word 2 times
words = [rng.choice(words) for _ignored2, words in preferred_words.items()]
rng.shuffle(words)
words = [(w, w) for w in words]
self.fields["word"].choices = words
class WEIBusInformation2022(WEIBusInformation):
"""
For each word, the bus has a score
"""
scores: dict
def __init__(self, bus):
self.scores = {}
for word in WORDS:
self.scores[word] = 0.0
super().__init__(bus)
class WEISurveyInformation2022(WEISurveyInformation):
"""
We store the id of the selected bus. We store only the name, but is not used in the selection:
that's only for humans that try to read data.
"""
# Random seed that is stored at the first time to ensure that words are generated only once
seed = 0
step = 0
def __init__(self, registration):
for i in range(1, 21):
setattr(self, "word" + str(i), None)
super().__init__(registration)
class WEISurvey2022(WEISurvey):
"""
Survey for the year 2022.
"""
@classmethod
def get_year(cls):
return 2022
@classmethod
def get_survey_information_class(cls):
return WEISurveyInformation2022
def get_form_class(self):
return WEISurveyForm2022
def update_form(self, form):
"""
Filter the bus selector with the buses of the WEI.
"""
form.set_registration(self.registration)
@transaction.atomic
def form_valid(self, form):
word = form.cleaned_data["word"]
self.information.step += 1
setattr(self.information, "word" + str(self.information.step), word)
self.save()
@classmethod
def get_algorithm_class(cls):
return WEISurveyAlgorithm2022
def is_complete(self) -> bool:
"""
The survey is complete once the bus is chosen.
"""
return self.information.step == 20
@classmethod
@lru_cache()
def word_mean(cls, word):
"""
Calculate the mid-score given by all buses.
"""
buses = cls.get_algorithm_class().get_buses()
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
@lru_cache()
def score(self, bus):
if not self.is_complete():
raise ValueError("Survey is not ended, can't calculate score")
bus_info = self.get_algorithm_class().get_bus_information(bus)
# Score is the given score by the bus subtracted to the mid-score of the buses.
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
return s
@lru_cache()
def scores_per_bus(self):
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
@lru_cache()
def ordered_buses(self):
values = list(self.scores_per_bus().items())
values.sort(key=lambda item: -item[1])
return values
@classmethod
def clear_cache(cls):
cls.word_mean.cache_clear()
return super().clear_cache()
class WEISurveyAlgorithm2022(WEISurveyAlgorithm):
"""
The algorithm class for the year 2022.
We use Gale-Shapley algorithm to attribute 1y students into buses.
"""
@classmethod
def get_survey_class(cls):
return WEISurvey2022
@classmethod
def get_bus_information_class(cls):
return WEIBusInformation2022
def run_algorithm(self, display_tqdm=False):
"""
Gale-Shapley algorithm implementation.
We modify it to allow buses to have multiple "weddings".
"""
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
# Don't manage hardcoded people
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
# Reset previous algorithm run
for survey in surveys:
survey.free()
survey.save()
non_men = [s for s in surveys if s.registration.gender != 'male']
men = [s for s in surveys if s.registration.gender == 'male']
quotas = {}
registrations = self.get_registrations()
non_men_total = registrations.filter(~Q(gender='male')).count()
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
tqdm_obj = None
if display_tqdm:
from tqdm import tqdm
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
# Repartition for non men people first
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
quotas = {}
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = free_seats
if display_tqdm:
tqdm_obj.close()
from tqdm import tqdm
tqdm_obj = tqdm(total=len(men), desc="Hommes")
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
if display_tqdm:
tqdm_obj.close()
# Clear cache information after running algorithm
WEISurvey2022.clear_cache()
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
free_surveys = surveys.copy() # Remaining surveys
while free_surveys: # Some students are not affected
survey = free_surveys[0]
buses = survey.ordered_buses() # Preferences of the student
for bus, current_score in buses:
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
# Selected bus has free places. Put student in the bus
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
# Current bus has not enough places. Remove the least preferred student from the bus if existing
least_preferred_survey = None
least_score = -1
# Find the least student in the bus that has a lower score than the current student
for survey2 in surveys:
if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
continue
score2 = survey2.score(bus)
if current_score <= score2: # Ignore better students
continue
if least_preferred_survey is None or score2 < least_score:
least_preferred_survey = survey2
least_score = score2
if least_preferred_survey is not None:
# Remove the least student from the bus and put the current student in.
# If it does not exist, choose the next bus.
least_preferred_survey.free()
least_preferred_survey.save()
free_surveys.append(least_preferred_survey)
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
raise ValueError(f"User {survey.registration.user} has no free seat")
if tqdm_obj is not None:
tqdm_obj.n = len(surveys) - len(free_surveys)
tqdm_obj.refresh()

View File

@ -24,7 +24,15 @@ class Command(BaseCommand):
sid = transaction.savepoint()
algorithm = CurrentSurvey.get_algorithm_class()()
algorithm.run_algorithm()
try:
from tqdm import tqdm
del tqdm
display_tqdm = True
except ImportError:
display_tqdm = False
algorithm.run_algorithm(display_tqdm=display_tqdm)
output = options['output']
registrations = algorithm.get_registrations()
@ -34,8 +42,13 @@ class Command(BaseCommand):
for bus, members in per_bus.items():
output.write(bus.name + "\n")
output.write("=" * len(bus.name) + "\n")
_order = -1
for r in members:
output.write(r.user.username + "\n")
survey = CurrentSurvey(r)
for _order, (b, _score) in enumerate(survey.ordered_buses()):
if b == bus:
break
output.write(f"{r.user.username} ({_order + 1})\n")
output.write("\n")
if not options['doit']:

View File

@ -25,6 +25,7 @@ class TestWEIAlgorithm(TestCase):
email="wei2021@example.com",
date_start='2021-09-17',
date_end='2021-09-19',
year=2021,
)
self.buses = []

View File

@ -0,0 +1,110 @@
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import random
from django.contrib.auth.models import User
from django.test import TestCase
from ..forms.surveys.wei2022 import WEIBusInformation2022, WEISurvey2022, WORDS, WEISurveyInformation2022
from ..models import Bus, WEIClub, WEIRegistration
class TestWEIAlgorithm(TestCase):
"""
Run some tests to ensure that the WEI algorithm is working well.
"""
fixtures = ('initial',)
def setUp(self):
"""
Create some test data, with one WEI and 10 buses with random score attributions.
"""
self.wei = WEIClub.objects.create(
name="WEI 2022",
email="wei2022@example.com",
date_start='2022-09-16',
date_end='2022-09-18',
year=2022,
)
self.buses = []
for i in range(10):
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
self.buses.append(bus)
information = WEIBusInformation2022(bus)
for word in WORDS:
information.scores[word] = random.randint(0, 101)
information.save()
bus.save()
def test_survey_algorithm_small(self):
"""
There are only a few people in each bus, ensure that each person has its best bus
"""
# Add a few users
for i in range(10):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2022(registration)
for j in range(1, 21):
setattr(information, f'word{j}', random.choice(WORDS))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2022.get_algorithm_class()().run_algorithm()
# Ensure that everyone has its first choice
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2022(r)
preferred_bus = survey.ordered_buses()[0][0]
chosen_bus = survey.information.get_selected_bus()
self.assertEqual(preferred_bus, chosen_bus)
def test_survey_algorithm_full(self):
"""
Buses are full of first year people, ensure that they are happy
"""
# Add a lot of users
for i in range(95):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2022(registration)
for j in range(1, 21):
setattr(information, f'word{j}', random.choice(WORDS))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2022.get_algorithm_class()().run_algorithm()
penalty = 0
# Ensure that everyone seems to be happy
# We attribute a penalty for each user that didn't have its first choice
# The penalty is the square of the distance between the score of the preferred bus
# and the score of the attributed bus
# We consider it acceptable if the mean of this distance is lower than 5 %
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2022(r)
chosen_bus = survey.information.get_selected_bus()
buses = survey.ordered_buses()
score = min(v for bus, v in buses if bus == chosen_bus)
max_score = buses[0][1]
penalty += (max_score - score) ** 2
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %

View File

@ -782,7 +782,7 @@ class TestDefaultWEISurvey(TestCase):
WEISurvey.update_form(None, None)
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
self.assertEqual(CurrentSurvey.get_year(), 2021)
self.assertEqual(CurrentSurvey.get_year(), 2022)
class TestWeiAPI(TestAPI):

View File

@ -118,13 +118,13 @@ Exemples
{"F": [
"ADD",
["F", "source__balance"],
5000]
2000]
}
}
]
| si la destination est la note du club dont on est membre et si le montant est inférieur au solde de la source + 50 €,
autrement dit le solde final est au-dessus de -50 €.
| si la destination est la note du club dont on est membre et si le montant est inférieur au solde de la source + 20 €,
autrement dit le solde final est au-dessus de -20 €.
Masques de permissions

View File

@ -86,7 +86,7 @@ Génération
Les factures peuvent s'exporter au format PDF (là est tout leur intérêt). Pour cela, on utilise le template LaTeX
présent à l'adresse suivante :
`/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/master/templates/treasury/invoice_sample.tex>`_
`/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/main/templates/treasury/invoice_sample.tex>`_
On le remplit avec les données de la facture et les données du BDE, hard-codées. On copie le template rempli dans un
ficher tex dans un dossier temporaire. On fait ensuite 2 appels à ``pdflatex`` pour générer la facture au format PDF.

View File

@ -41,8 +41,14 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions
OAUTH2_PROVIDER = {
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
}
Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
et de demander des scopes facultatives (voir plus bas).
Un jeton de rafraîchissement expire de plus au bout de 14 jours, si non-renouvelé.
On ajoute enfin les routes dans ``urls.py`` :
.. code:: python
@ -94,6 +100,27 @@ du format renvoyé.
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
jetons.
.. danger::
Demander des scopes n'implique pas de les avoir.
Lorsque des scopes sont demandées par un client, la Note
va considérer l'ensemble des permissions accessibles parmi
ce qui est demandé. Dans vos programmes, vous devrez donc
vérifier les permissions acquises (communiquées lors de la
récupération du jeton d'accès à partir du grant code),
et prévoir un comportement dans le cas où des permissions
sont manquantes.
Cela offre un intérêt supérieur par rapport au protocole
OAuth2 classique, consistant à demander trop de permissions
et agir en conséquence.
Par exemple, vous pourriez demander la permission d'accéder
aux membres d'un club ou de faire des transactions, et agir
uniquement dans le cas où l'utilisateur connecté possède la
permission problématique.
Avec Django-allauth
###################
@ -116,6 +143,7 @@ installées (sur votre propre client), puis de bien ajouter l'application social
SOCIALACCOUNT_PROVIDERS = {
'notekfet': {
# 'DOMAIN': 'note.crans.org',
'SCOPE': ['1_1', '2_1'],
},
...
}
@ -123,6 +151,10 @@ installées (sur votre propre client), puis de bien ajouter l'application social
Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il
se connectera à ``note.crans.org`` si vous ne renseignez rien.
Le paramètre ``SCOPE`` permet de définir les scopes à demander.
Dans l'exemple ci-dessous, les permissions d'accéder à l'utilisateur
et au profil sont demandées.
En créant l'application sur la note, vous pouvez renseigner
``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection,
à adapter selon votre configuration.

View File

@ -83,13 +83,6 @@ Je suis trésorier d'un club, qu'ai-je le droit de faire ?
bien sûr permis pour faciliter des transferts. Tout abus de droits constaté
pourra mener à des sanctions prises par le bureau du BDE.
.. warning::
Une fonctionnalité pour permettre de gérer plus proprement les remboursements
entre amis est en cours de développement. Temporairement et pour des raisons
de confort, les trésoriers de clubs ont le droit de prélever n'importe quelle
adhérente vers n'importe quelle autre note adhérente, tant que la source ne
descend pas sous ``- 50 €``. Ces droits seront retirés d'ici quelques semaines.
Je suis trésorier d'un club, je n'arrive pas à voir le solde du club / faire des transactions
---------------------------------------------------------------------------------------------------

View File

@ -88,7 +88,7 @@ On clone donc le dépôt en tant que ``www-data`` :
$ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git /var/www/note_kfet
Par défaut, le dépôt est configuré pour suivre la branche ``master``, qui est la branche
Par défaut, le dépôt est configuré pour suivre la branche ``main``, qui est la branche
stable, notamment installée sur `<https://note.crans.org/>`_. Pour changer de branche,
notamment passer sur la branche ``beta`` sur un serveur de pré-production (un peu comme
`<https://note-dev.crans.org/>`_), on peut faire :
@ -587,7 +587,7 @@ Dans ce fichier, remplissez :
---
note:
server_name: note.crans.org
git_branch: master
git_branch: main
cron_enabled: true
email: notekfet2020@lists.crans.org

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,8 @@
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
from datetime import timedelta
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Quick-start development settings - unsuitable for production
@ -22,6 +24,15 @@ ALLOWED_HOSTS = [
os.getenv('NOTE_URL', 'localhost'),
]
# Use secure cookies in production
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
# Remember HTTPS for 1 year
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Application definition
@ -241,13 +252,15 @@ REST_FRAMEWORK = {
'rest_framework.authentication.TokenAuthentication',
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'DEFAULT_PAGINATION_CLASS': 'apps.api.pagination.CustomPagination',
'PAGE_SIZE': 20,
}
# OAuth2 Provider
OAUTH2_PROVIDER = {
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
}
# Take control on how widget templates are sourced

View File

@ -65,7 +65,7 @@ mark {
/* Last BDE colors */
.bg-primary {
background-color: rgb(18, 67, 4) !important;
background-color: rgb(102, 83, 105) !important;
}
html {
@ -81,14 +81,14 @@ body {
.btn-outline-primary:not(:disabled):not(.disabled).active,
.btn-outline-primary:not(:disabled):not(.disabled):active {
color: #fff;
background-color: rgb(18, 67, 46);
border-color: rgb(18, 67, 46);
background-color: rgb(102, 83, 105);
border-color: rgb(102, 83, 105);
}
.btn-outline-primary {
color: rgb(18, 67, 46);
color: rgb(102, 83, 105);
background-color: rgba(248, 249, 250, 0.9);
border-color: rgb(18, 67, 46);
border-color: rgb(102, 83, 105);
}
.turbolinks-progress-bar {
@ -99,35 +99,35 @@ body {
.btn-primary:not(:disabled):not(.disabled).active,
.btn-primary:not(:disabled):not(.disabled):active {
color: #fff;
background-color: rgb(18, 67, 46);
border-color: rgb(18, 67, 46);
background-color: rgb(102, 83, 105);
border-color: rgb(102, 83, 105);
}
.btn-primary {
color: rgba(248, 249, 250, 0.9);
background-color: rgb(28, 114, 10);
border-color: rgb(18, 67, 46);
background-color: rgb(102, 83, 105);
border-color: rgb(102, 83, 105);
}
.border-primary {
border-color: rgb(28, 114, 10) !important;
border-color: rgb(115, 15, 115) !important;
}
a {
color: rgb(28, 114, 10);
color: rgb(102, 83, 105);
}
a:hover {
color: rgb(122, 163, 75);
color: rgb(200, 30, 200);
}
.form-control:focus {
box-shadow: 0 0 0 0.25rem rgba(122, 163, 75, 0.25);
border-color: rgb(122, 163, 75);
box-shadow: 0 0 0 0.25rem rgba(200, 30, 200, 0.25);
border-color: rgb(200, 30, 200);
}
.btn-outline-primary.focus {
box-shadow: 0 0 0 0.25rem rgba(122, 163, 75, 0.5);
box-shadow: 0 0 0 0.25rem rgba(200, 30, 200, 0.5);
}

View File

@ -13,21 +13,29 @@ $(document).ready(function () {
$('#' + prefix + '_reset').removeClass('d-none')
$.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) {
let html = ''
let html = '<ul class="list-group list-group-flush" id="' + prefix + '_list">'
objects.results.forEach(function (obj) {
html += li(prefix + '_' + obj.id, obj[name_field])
})
html += '</ul>'
const results_list = $('#' + prefix + '_list')
results_list.html(html)
target.tooltip({
html: true,
placement: 'bottom',
trigger: 'manual',
container: target.parent(),
fallbackPlacement: 'clockwise'
})
target.attr("data-original-title", html).tooltip("show")
objects.results.forEach(function (obj) {
$('#' + prefix + '_' + obj.id).click(function () {
target.val(obj[name_field])
$('#' + prefix + '_pk').val(obj.id)
results_list.html('')
target.tooltip("hide")
target.removeClass('is-invalid')
target.addClass('is-valid')
@ -37,8 +45,8 @@ $(document).ready(function () {
if (input === obj[name_field]) { $('#' + prefix + '_pk').val(obj.id) }
})
if (results_list.children().length === 1 && e.originalEvent.keyCode >= 32) {
results_list.children().first().trigger('click')
if (objects.results.length === 1 && e.originalEvent.keyCode >= 32) {
$('#' + prefix + '_' + objects.results[0].id).trigger('click')
}
})
})

View File

@ -7,8 +7,8 @@
* @returns {string}
*/
function pretty_money (value) {
if (value % 100 === 0) { return (value < 0 ? '- ' : '') + Math.round(Math.abs(value) / 100) + ' €' } else {
return (value < 0 ? '- ' : '') + Math.round(Math.abs(value) / 100) + '.' +
if (value % 100 === 0) { return (value < 0 ? '- ' : '') + Math.floor(Math.abs(value) / 100) + ' €' } else {
return (value < 0 ? '- ' : '') + Math.floor(Math.abs(value) / 100) + '.' +
(Math.abs(value) % 100 < 10 ? '0' : '') + (Math.abs(value) % 100) + ' €'
}
}
@ -96,7 +96,11 @@ function displayStyle (note) {
if (!note) { return '' }
const balance = note.balance
var css = ''
if (balance < -5000) { css += ' text-danger bg-dark' } else if (balance < -1000) { css += ' text-danger' } else if (balance < 0) { css += ' text-warning' } else if (!note.email_confirmed) { css += ' text-white bg-primary' } else if (!note.is_active || (note.membership && note.membership.date_end < new Date().toISOString())) { css += 'text-white bg-info' }
if (balance < -2000) { css += ' text-danger bg-dark' }
else if (balance < -1000) { css += ' text-danger' }
else if (balance < 0) { css += ' text-warning' }
if (!note.email_confirmed) { css += ' bg-primary' }
else if (!note.is_active || (note.membership && note.membership.date_end < new Date().toISOString())) { css += ' bg-info' }
return css
}
@ -377,11 +381,11 @@ function de_validate (id, validated, resourcetype) {
* @param callback Function to call
* @param wait Debounced milliseconds
*/
function debounce (callback, wait) {
let timeout
let debounce_timeout
function debounce (callback, wait=500) {
return (...args) => {
const context = this
clearTimeout(timeout)
timeout = setTimeout(() => callback.apply(context, args), wait)
clearTimeout(debounce_timeout)
debounce_timeout = setTimeout(() => callback.apply(context, args), wait)
}
}

File diff suppressed because one or more lines are too long

View File

@ -9,9 +9,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
name="{{ widget.name }}_name" autocomplete="off"
{% for name, value in widget.attrs.items %}
{% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
{% endfor %}>
{% endfor %}
aria-describedby="{{widget.attrs.id}}_tooltip">
{% if widget.resetable %}
<a id="{{ widget.attrs.id }}_reset" class="btn btn-light autocomplete-reset{% if not widget.value %} d-none{% endif %}">{% trans "Reset" %}</a>
{% endif %}
<ul class="list-group list-group-flush" id="{{ widget.attrs.id }}_list">
</ul>

View File

@ -33,8 +33,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static "jquery/jquery.min.js" %}"></script>
<script src="{% static "popper.js/umd/popper.min.js" %}"></script>
<script src="{% static "bootstrap4/js/bootstrap.min.js" %}"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js"
crossorigin="anonymous"></script>
<script src="{% static "js/turbolinks.js" %}"></script>
<script src="{% static "js/base.js" %}"></script>
<script src="{% static "js/konami.js" %}"></script>
@ -193,6 +192,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<span class="text-muted mr-1">
<a href="mailto:{{ "CONTACT_EMAIL" | getenv }}"
class="text-muted">{% trans "Contact us" %}</a> &mdash;
<a href="mailto:{{ "SUPPORT_EMAIL" | getenv }}"
class="text-muted">{% trans "Technical Support" %}</a> &mdash;
</span>
{% csrf_token %}
<select title="language" name="language"

View File

@ -23,11 +23,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% csrf_token %}
{{ form|crispy }}
{{ profile_form|crispy }}
{{ soge_form|crispy }}
{% comment "Soge not for membership (only WEI)" %} {{ soge_form|crispy }} {% endcomment %}
<button class="btn btn-success" type="submit">
{% trans "Sign up" %}
</button>
</form>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -35,8 +35,9 @@ urlpatterns = [
path('coffee/', include('django_htcpcp_tea.urls')),
]
# During development, serve media files
# During development, serve static and media files
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if "oauth2_provider" in settings.INSTALLED_APPS:

View File

@ -4,14 +4,14 @@ django-bootstrap-datepicker-plus~=3.0.5
django-cas-server~=1.2.0
django-colorfield~=0.3.2
django-crispy-forms~=1.7.2
django-extensions~=2.1.4
django-filter~=2.1.0
django-extensions>=2.1.4
django-filter~=2.1
django-htcpcp-tea~=0.3.1
django-mailer~=2.0.1
django-oauth-toolkit~=1.3.3
django-phonenumber-field~=5.0.0
django-polymorphic~=2.0.3
djangorestframework~=3.9.0
django-polymorphic>=2.0.3,<3.0.0
djangorestframework>=3.9.0,<3.13.0
django-rest-polymorphic~=0.1.9
django-tables2~=2.3.1
python-memcached~=1.59