mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-07-03 06:12:47 +02:00
Compare commits
179 Commits
9930c48253
...
update_per
Author | SHA1 | Date | |
---|---|---|---|
03d2d5f03e | |||
d2057a9f45 | |||
6b1cd3ba7a | |||
9f114b8ca2 | |||
e0132b6dc8 | |||
31e67ae3f6 | |||
2839d3de1e | |||
30afa6da0a | |||
84fc77696f | |||
19fc620d1f | |||
6bceb394c5 | |||
62cf8f9d84 | |||
2dd1c3fb89 | |||
c8665c5798 | |||
e9f1b6f52d | |||
1d95ae4810 | |||
84b16ab603 | |||
6a1b51dbbf | |||
c441a43a8b | |||
87f3b51b04 | |||
0a853fd3e6 | |||
c429734810 | |||
5d759111b6 | |||
70baf7566c | |||
eb355f547c | |||
7068170f18 | |||
45ee9a8941 | |||
454ea19603 | |||
5a77a66391 | |||
761fc170eb
|
|||
ac23d7eb54
|
|||
40e7415062
|
|||
319405d2b1
|
|||
633ab88b04
|
|||
e29b42eecc
|
|||
dc69faaf1d
|
|||
442a5c5e36
|
|||
7ab0fec3bc
|
|||
bd4fb23351 | |||
ee22e9b3b6 | |||
19ae616fb4 | |||
b7657ec362 | |||
4d03d9460d | |||
3633f66a87 | |||
d43fbe7ac6 | |||
df5f9b5f1e | |||
4161248bff
|
|||
58136f3c48
|
|||
d9b4e0a9a9
|
|||
8563a8d235
|
|||
5f69232560 | |||
d3273e9ee2
|
|||
4e30f805a7 | |||
546e422e64
|
|||
9048a416df
|
|||
8578bd743c
|
|||
45a10dad00
|
|||
18a1282773
|
|||
132afc3d15
|
|||
6bf16a181a
|
|||
e20df82346
|
|||
1eb72044c2 | |||
f88eae924c
|
|||
4b6e3ba546
|
|||
bf0fe3479f | |||
45ba4f9537
|
|||
b204805ce2
|
|||
2f28e34cec
|
|||
9c8ea2cd41
|
|||
41289857b2 | |||
28a8792c9f
|
|||
58cafad032
|
|||
7848cd9cc2
|
|||
d18ccfac23
|
|||
e479e1e3a4 | |||
82b0c83b1f | |||
38ca414ef6
|
|||
fd811053c7
|
|||
9d386d1ecf
|
|||
0bd447b608 | |||
3f3c93d928 | |||
340c90f5d3 | |||
ca2b9f061c | |||
a05dfcbf3d
|
|||
ba3c0fb18d
|
|||
ab69963ea1 | |||
654c01631a
|
|||
d94cc2a7ad
|
|||
69bb38297f
|
|||
9628560d64
|
|||
df3bb71357
|
|||
2a216fd994
|
|||
8dd2619013
|
|||
62431a4910
|
|||
946bc1e497 | |||
d4896bfd76
|
|||
23f46cc598
|
|||
d1a9f21b56 | |||
d809b2595a
|
|||
97803ac983 | |||
b951c4aa05 | |||
69b3d2ac9c
|
|||
f29054558a
|
|||
11dd8adbb7 | |||
d437f2bdbd
|
|||
ac8453b04c
|
|||
6b4d18f4b3 | |||
668cfa71a7 | |||
161db0b00b
|
|||
8638c16b34
|
|||
9583cec3ff
|
|||
1ef25924a0
|
|||
e89383e3f4
|
|||
79a116d9c6
|
|||
aa75ce5c7a
|
|||
a3a9dfc812
|
|||
76531595ad
|
|||
a0b920ac94
|
|||
ab2e580e68
|
|||
0234f19a33
|
|||
1a4b7c83e8
|
|||
4c17e2a92b
|
|||
e68afc7d0a
|
|||
c6e3b54f94
|
|||
7e6a14296a | |||
780f78b385 | |||
4e3c32eb5e
|
|||
ef118c2445
|
|||
600ba15faa
|
|||
944bb127e2
|
|||
f6d042c998
|
|||
bb9a0a2593
|
|||
61feac13c7
|
|||
81e708a7e3
|
|||
3532846c87
|
|||
49551e88f8
|
|||
db936bf75a
|
|||
5828a20383 | |||
cea3138daf | |||
fb98d9cd8b
|
|||
0dd3da5c01
|
|||
af4be98b5b
|
|||
be6059eba6
|
|||
5793b83de7
|
|||
2c02c747f4
|
|||
a78f3b7caa
|
|||
1ee40cb94e
|
|||
bd035744a4
|
|||
7edd622755
|
|||
8fd5b6ee01
|
|||
03411ac9bd
|
|||
d965732b65
|
|||
048266ed61
|
|||
b27341009e
|
|||
da1e15c5e6
|
|||
4b03a78ad6
|
|||
fb6e3c3de0
|
|||
391f3bde8f
|
|||
ad04e45992
|
|||
4e1ba1447a
|
|||
b646f549d6
|
|||
ba9ef0371a
|
|||
881cd88f48
|
|||
b4ed354b73 | |||
e5051ab018
|
|||
bb69627ac5
|
|||
ffaa020310
|
|||
6d2b7054e2
|
|||
d888d5863a
|
|||
dbc7b3444b
|
|||
f25eb1d2c5
|
|||
a2a749e1ca
|
|||
5bf6a5501d
|
|||
9523b5f05f
|
|||
5eb3ffca66 | |||
789ca149af | |||
08ba0b263a | |||
4583958f50 | |||
bab394908d |
1
.gitignore
vendored
1
.gitignore
vendored
@ -47,6 +47,7 @@ backups/
|
||||
env/
|
||||
venv/
|
||||
db.sqlite3
|
||||
shell.nix
|
||||
|
||||
# ansibles customs host
|
||||
ansible/host_vars/*.yaml
|
||||
|
@ -1,8 +1,8 @@
|
||||
# NoteKfet 2020
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||
|
||||
## Table des matières
|
||||
|
||||
|
@ -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
|
||||
|
@ -1,6 +0,0 @@
|
||||
---
|
||||
note:
|
||||
server_name: note-beta.crans.org
|
||||
git_branch: beta
|
||||
cron_enabled: false
|
||||
email: notekfet2020@lists.crans.org
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -1,6 +1,5 @@
|
||||
[dev]
|
||||
bde-note-dev.adh.crans.org
|
||||
bde-nk20-beta.adh.crans.org
|
||||
|
||||
[prod]
|
||||
bde-note.adh.crans.org
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -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"]
|
||||
|
@ -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):
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
5
apps/api/pagination.py
Normal file
@ -0,0 +1,5 @@
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
class CustomPagination(PageNumberPagination):
|
||||
page_size_query_param = 'page_size'
|
||||
|
@ -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
|
||||
|
@ -2,10 +2,12 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher, mask_hash
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
|
||||
@ -47,6 +49,18 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
|
||||
return constant_time_compare(hashlib.sha256((salt + password).encode("utf-8")).hexdigest(), db_hashed_pass)
|
||||
return super().verify(password, encoded)
|
||||
|
||||
def safe_summary(self, encoded):
|
||||
# Displayed information in Django Admin.
|
||||
if '|' in encoded:
|
||||
salt, db_hashed_pass = encoded.split('$')[2].split('|')
|
||||
return OrderedDict([
|
||||
(_('algorithm'), 'custom_nk15'),
|
||||
(_('iterations'), '1'),
|
||||
(_('salt'), mask_hash(salt)),
|
||||
(_('hash'), mask_hash(db_hashed_pass)),
|
||||
])
|
||||
return super().safe_summary(encoded)
|
||||
|
||||
|
||||
class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
|
||||
"""
|
||||
|
@ -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(
|
||||
|
18
apps/member/migrations/0008_auto_20211005_1544.py
Normal file
18
apps/member/migrations/0008_auto_20211005_1544.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -57,7 +57,7 @@ class Profile(models.Model):
|
||||
('A1', _("Mathematics (A1)")),
|
||||
('A2', _("Physics (A2)")),
|
||||
("A'2", _("Applied physics (A'2)")),
|
||||
('A''2', _("Chemistry (A''2)")),
|
||||
("A''2", _("Chemistry (A''2)")),
|
||||
('A3', _("Biology (A3)")),
|
||||
('B1234', _("SAPHIRE (B1234)")),
|
||||
('B1', _("Mechanics (B1)")),
|
||||
@ -74,7 +74,7 @@ class Profile(models.Model):
|
||||
|
||||
promotion = models.PositiveSmallIntegerField(
|
||||
null=True,
|
||||
default=datetime.date.today().year,
|
||||
default=datetime.date.today().year if datetime.date.today().month >= 8 else datetime.date.today().year - 1,
|
||||
verbose_name=_("promotion"),
|
||||
help_text=_("Year of entry to the school (None if not ENS student)"),
|
||||
)
|
||||
@ -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():
|
||||
|
53
apps/member/static/member/js/trust.js
Normal file
53
apps/member/static/member/js/trust.js
Normal 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)
|
||||
})
|
@ -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,
|
||||
|
@ -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>
|
||||
@ -39,13 +47,13 @@
|
||||
<dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ user_object.profile.address }}</dd>
|
||||
|
||||
{% if user_object.note and "note.view_note"|has_perm:user_object.note %}
|
||||
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'paid'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ user_object.profile.paid|yesno }}</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if user_object.note and "note.view_note"|has_perm:user_object.note %}
|
||||
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ user_object.note.balance | pretty_money }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
|
41
apps/member/templates/member/profile_trust.html
Normal file
41
apps/member/templates/member/profile_trust.html
Normal 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%}
|
@ -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'),
|
||||
]
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -1,5 +1,6 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@ -13,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
|
||||
|
||||
|
||||
@ -55,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
|
||||
@ -133,23 +165,31 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
|
||||
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
|
||||
|
||||
alias = self.request.query_params.get("alias", None)
|
||||
# Check if this is a valid regex. If not, we won't check regex
|
||||
try:
|
||||
re.compile(alias)
|
||||
valid_regex = True
|
||||
except (re.error, TypeError):
|
||||
valid_regex = False
|
||||
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||
alias_prefix = '^' if valid_regex else ''
|
||||
queryset = queryset.prefetch_related('note')
|
||||
|
||||
if alias:
|
||||
# We match first an alias if it is matched without normalization,
|
||||
# then if the normalized pattern matches a normalized alias.
|
||||
queryset = queryset.filter(
|
||||
name__iregex="^" + alias
|
||||
**{f'name{suffix}': alias_prefix + alias}
|
||||
).union(
|
||||
queryset.filter(
|
||||
Q(normalized_name__iregex="^" + Alias.normalize(alias))
|
||||
& ~Q(name__iregex="^" + alias)
|
||||
Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
|
||||
& ~Q(**{f'name{suffix}': alias_prefix + alias})
|
||||
),
|
||||
all=True).union(
|
||||
queryset.filter(
|
||||
Q(normalized_name__iregex="^" + alias.lower())
|
||||
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
|
||||
& ~Q(name__iregex="^" + alias)
|
||||
Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
|
||||
& ~Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
|
||||
& ~Q(**{f'name{suffix}': alias_prefix + alias})
|
||||
),
|
||||
all=True)
|
||||
|
||||
|
27
apps/note/migrations/0006_trust.py
Normal file
27
apps/note/migrations/0006_trust.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
@ -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',
|
||||
|
@ -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.
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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 }}
|
||||
|
@ -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 %}
|
||||
|
@ -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)
|
||||
)
|
||||
|
||||
|
@ -159,6 +159,10 @@ class PermissionBackend(ModelBackend):
|
||||
primary key, the result is not memoized. Moreover, the right could change
|
||||
(e.g. for a transaction, the balance of the user could change)
|
||||
"""
|
||||
# Requested by a shell
|
||||
if request is None:
|
||||
return False
|
||||
|
||||
user_obj = request.user
|
||||
sess = request.session
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
19
apps/permission/migrations/0002_club_not_required.py
Normal file
19
apps/permission/migrations/0002_club_not_required.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -339,6 +339,7 @@ class Role(models.Model):
|
||||
"member.Club",
|
||||
verbose_name=_("for club"),
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -61,6 +61,12 @@ def pre_save_object(sender, instance, **kwargs):
|
||||
# If the field wasn't modified, no need to check the permissions
|
||||
if old_value == new_value:
|
||||
continue
|
||||
|
||||
if app_label == 'auth' and model_name == 'user' and field.name == 'password' and request.user.is_anonymous:
|
||||
# We must ignore password changes from anonymous users since it can be done by people that forgot
|
||||
# their password. We trust password change form.
|
||||
continue
|
||||
|
||||
if not PermissionBackend.check_perm(request, app_label + ".change_" + model_name + "_" + field_name,
|
||||
instance):
|
||||
raise PermissionDenied(
|
||||
|
@ -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>'
|
||||
|
@ -46,7 +46,8 @@ class SignUpForm(UserCreationForm):
|
||||
|
||||
class DeclareSogeAccountOpenedForm(forms.Form):
|
||||
soge_account = forms.BooleanField(
|
||||
label=_("I declare that I opened 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,
|
||||
|
@ -85,6 +85,9 @@ class UserCreateView(CreateView):
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
# Direct access to validation menu if we have the right to validate it
|
||||
if PermissionBackend.check_perm(self.request, 'auth.view_user', self.object):
|
||||
return reverse_lazy('registration:future_user_detail', args=(self.object.pk,))
|
||||
return reverse_lazy('registration:email_validation_sent')
|
||||
|
||||
|
||||
|
Submodule apps/scripts updated: 7a022b9407...86bc2d2698
@ -1,6 +1,6 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
from note.api.serializers import SpecialTransactionSerializer
|
||||
|
||||
@ -68,6 +68,14 @@ class SogeCreditSerializer(serializers.ModelSerializer):
|
||||
The djangorestframework plugin will analyse the model `SogeCredit` and parse all fields in the API.
|
||||
"""
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, **kwargs):
|
||||
# Update soge transactions after creating a credit
|
||||
instance = super().save(**kwargs)
|
||||
instance.update_transactions()
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
class Meta:
|
||||
model = SogeCredit
|
||||
fields = '__all__'
|
||||
|
@ -4,11 +4,12 @@
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Submit
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.inputs import AmountInput
|
||||
from note_kfet.inputs import AmountInput, Autocomplete
|
||||
|
||||
from .models import Invoice, Product, Remittance, SpecialTransactionProxy
|
||||
from .models import Invoice, Product, Remittance, SpecialTransactionProxy, SogeCredit
|
||||
|
||||
|
||||
class InvoiceForm(forms.ModelForm):
|
||||
@ -161,3 +162,19 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SpecialTransactionProxy
|
||||
fields = ('remittance', )
|
||||
|
||||
|
||||
class SogeCreditForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SogeCredit
|
||||
fields = ('user', )
|
||||
widgets = {
|
||||
"user": Autocomplete(
|
||||
User,
|
||||
attrs={
|
||||
'api_url': '/api/user/',
|
||||
'name_field': 'username',
|
||||
'placeholder': 'Nom ...',
|
||||
},
|
||||
),
|
||||
}
|
||||
|
18
apps/treasury/migrations/0004_auto_20211005_1544.py
Normal file
18
apps/treasury/migrations/0004_auto_20211005_1544.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -1,8 +1,9 @@
|
||||
# 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
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
@ -11,6 +12,7 @@ from django.db.models import Q
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club, Membership
|
||||
from note.models import NoteSpecial, SpecialTransaction, MembershipTransaction, NoteUser
|
||||
|
||||
|
||||
@ -286,6 +288,7 @@ class SogeCredit(models.Model):
|
||||
transactions = models.ManyToManyField(
|
||||
MembershipTransaction,
|
||||
related_name="+",
|
||||
blank=True,
|
||||
verbose_name=_("membership transactions"),
|
||||
)
|
||||
|
||||
@ -302,8 +305,56 @@ 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):
|
||||
"""
|
||||
The Sogé credit may be created after the user already paid its memberships.
|
||||
We query transactions and update the credit, if it is unvalid.
|
||||
"""
|
||||
if self.valid or not self.pk:
|
||||
return
|
||||
|
||||
bde = Club.objects.get(name="BDE")
|
||||
kfet = Club.objects.get(name="Kfet")
|
||||
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)
|
||||
|
||||
## 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
|
||||
wei = WEIClub.objects.order_by('-year').first()
|
||||
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 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
|
||||
tr.save()
|
||||
|
||||
def invalidate(self):
|
||||
"""
|
||||
@ -365,7 +416,8 @@ class SogeCredit(models.Model):
|
||||
self.credit_transaction.amount = self.amount
|
||||
self.credit_transaction._force_save = True
|
||||
self.credit_transaction.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, **kwargs):
|
||||
"""
|
||||
@ -392,6 +444,7 @@ class SogeCredit(models.Model):
|
||||
# was opened after the validation of the account.
|
||||
self.credit_transaction.valid = False
|
||||
self.credit_transaction.reason += " (invalide)"
|
||||
self.credit_transaction._force_save = True
|
||||
self.credit_transaction.save()
|
||||
super().delete(**kwargs)
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
@ -27,7 +28,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
|
||||
<div class="input-group">
|
||||
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
|
||||
<div class="input-group-append">
|
||||
<button id="add_sogecredit" class="btn btn-success" data-toggle="modal" data-target="#add-sogecredit-modal">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="invalid_only" class="form-check-label">
|
||||
<input id="invalid_only" name="invalid_only" type="checkbox" class="checkboxinput form-check-input" checked>
|
||||
@ -47,28 +53,65 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Popup to add new Soge credits manually if needed #}
|
||||
<div class="modal fade" id="add-sogecredit-modal" tabindex="-1" role="dialog" aria-labelledby="addSogeCredit"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="lockNote">{% trans "Add credit from the Société générale" %}</h5>
|
||||
<button type="button" class="close btn-modal" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ form|crispy }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-modal" data-dismiss="modal">{% trans "Close" %}</button>
|
||||
<button type="button" class="btn btn-success btn-modal" data-dismiss="modal" onclick="addSogeCredit()">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
let old_pattern = null;
|
||||
let searchbar_obj = $("#searchbar");
|
||||
let invalid_only_obj = $("#invalid_only");
|
||||
let old_pattern = null;
|
||||
let searchbar_obj = $("#searchbar");
|
||||
let invalid_only_obj = $("#invalid_only");
|
||||
|
||||
function reloadTable() {
|
||||
let pattern = searchbar_obj.val();
|
||||
function reloadTable() {
|
||||
let pattern = searchbar_obj.val();
|
||||
|
||||
$("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
|
||||
invalid_only_obj.is(':checked') ? "" : "&valid=1") + " #credits_table");
|
||||
$("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
|
||||
invalid_only_obj.is(':checked') ? "" : "&valid=1") + " #credits_table");
|
||||
|
||||
$(".table-row").click(function () {
|
||||
window.document.location = $(this).data("href");
|
||||
});
|
||||
}
|
||||
$(".table-row").click(function () {
|
||||
window.document.location = $(this).data("href");
|
||||
});
|
||||
}
|
||||
|
||||
searchbar_obj.keyup(reloadTable);
|
||||
invalid_only_obj.change(reloadTable);
|
||||
});
|
||||
searchbar_obj.keyup(reloadTable);
|
||||
invalid_only_obj.change(reloadTable);
|
||||
|
||||
function addSogeCredit() {
|
||||
let user_pk = $('#id_user_pk').val()
|
||||
if (!user_pk)
|
||||
return
|
||||
|
||||
$.post('/api/treasury/soge_credit/?format=json', {
|
||||
csrfmiddlewaretoken: CSRF_TOKEN,
|
||||
user: user_pk,
|
||||
}).done(function() {
|
||||
addMsg("{% trans "Credit successfully registered" %}", 'success', 10000)
|
||||
reloadTable()
|
||||
}).fail(function (xhr) {
|
||||
errMsg(xhr.responseJSON, 30000)
|
||||
reloadTable()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
@ -25,7 +25,8 @@ from note_kfet.settings.base import BASE_DIR
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
|
||||
from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, LinkTransactionToRemittanceForm
|
||||
from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, \
|
||||
LinkTransactionToRemittanceForm, SogeCreditForm
|
||||
from .models import Invoice, Product, Remittance, SpecialTransactionProxy, SogeCredit
|
||||
from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable, SogeCreditTable
|
||||
|
||||
@ -107,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)
|
||||
|
||||
@ -433,6 +434,11 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
|
||||
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['form'] = SogeCreditForm(self.request.POST or None)
|
||||
return context
|
||||
|
||||
|
||||
class SogeCreditManageView(LoginRequiredMixin, ProtectQuerysetMixin, BaseFormView, DetailView):
|
||||
"""
|
||||
|
@ -1,10 +1,10 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .registration import WEIForm, WEIRegistrationForm, WEIMembershipForm, BusForm, BusTeamForm
|
||||
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, WEIMembershipForm, BusForm, BusTeamForm
|
||||
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
|
||||
|
||||
__all__ = [
|
||||
'WEIForm', 'WEIRegistrationForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||
]
|
||||
|
@ -6,7 +6,7 @@ from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note.models import NoteSpecial
|
||||
from note.models import NoteSpecial, NoteUser
|
||||
from note_kfet.inputs import AmountInput, DatePickerInput, Autocomplete, ColorWidget
|
||||
|
||||
from ..models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership, WEIRole
|
||||
@ -27,6 +27,15 @@ class WEIForm(forms.ModelForm):
|
||||
|
||||
|
||||
class WEIRegistrationForm(forms.ModelForm):
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if 'user' in cleaned_data:
|
||||
if not NoteUser.objects.filter(user=cleaned_data['user']).exists():
|
||||
self.add_error('user', _("The selected user is not validated. Please validate its account first"))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = WEIRegistration
|
||||
exclude = ('wei', )
|
||||
@ -39,8 +48,7 @@ class WEIRegistrationForm(forms.ModelForm):
|
||||
'placeholder': 'Nom ...',
|
||||
},
|
||||
),
|
||||
"birth_date": DatePickerInput(options={'defaultDate': '2000-01-01',
|
||||
'minDate': '1900-01-01',
|
||||
"birth_date": DatePickerInput(options={'minDate': '1900-01-01',
|
||||
'maxDate': '2100-01-01'}),
|
||||
}
|
||||
|
||||
@ -109,7 +117,8 @@ class WEIMembershipForm(forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if cleaned_data["team"] is not None and cleaned_data["team"].bus != cleaned_data["bus"]:
|
||||
if 'team' in cleaned_data and cleaned_data["team"] is not None \
|
||||
and cleaned_data["team"].bus != cleaned_data["bus"]:
|
||||
self.add_error('bus', _("This team doesn't belong to the given bus."))
|
||||
return cleaned_data
|
||||
|
||||
@ -135,6 +144,20 @@ class WEIMembershipForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class WEIMembership1AForm(WEIMembershipForm):
|
||||
"""
|
||||
Used to confirm registrations of first year members without choosing a bus now.
|
||||
"""
|
||||
roles = None
|
||||
|
||||
def clean(self):
|
||||
return super(forms.ModelForm, self).clean()
|
||||
|
||||
class Meta:
|
||||
model = WEIMembership
|
||||
fields = ('credit_type', 'credit_amount', 'last_name', 'first_name', 'bank',)
|
||||
|
||||
|
||||
class BusForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Bus
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
@ -40,19 +44,31 @@ class WEISurveyForm2021(forms.Form):
|
||||
if not information.seed:
|
||||
information.seed = int(1000 * time.time())
|
||||
information.save(registration)
|
||||
registration._force_save = True
|
||||
registration.save()
|
||||
|
||||
rng = Random(information.seed)
|
||||
|
||||
words = []
|
||||
for _ignored in range(information.step + 1):
|
||||
# Generate N times words
|
||||
words = [rng.choice(WORDS) for _ignored2 in range(10)]
|
||||
words = [(w, w) for w in words]
|
||||
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 = WEISurveyAlgorithm2021.get_buses()
|
||||
informations = {bus: WEIBusInformation2021(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
|
||||
|
||||
|
||||
@ -123,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):
|
||||
"""
|
||||
@ -152,18 +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
|
||||
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()
|
||||
@ -171,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
|
||||
@ -193,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()
|
||||
|
296
apps/wei/forms/surveys/wei2022.py
Normal file
296
apps/wei/forms/surveys/wei2022.py
Normal 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()
|
50
apps/wei/management/commands/import_scores.py
Normal file
50
apps/wei/management/commands/import_scores.py
Normal file
@ -0,0 +1,50 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from ...forms import CurrentSurvey
|
||||
from ...forms.surveys.wei2021 import WORDS # WARNING: this is specific to 2021
|
||||
from ...models import Bus
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
This script is used to load scores for buses from a CSV file.
|
||||
"""
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('file', nargs='?', type=argparse.FileType('r'), default=sys.stdin, help='Input CSV file')
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
file = options['file']
|
||||
head = file.readline().replace('\n', '')
|
||||
bus_names = head.split(';')
|
||||
bus_names = [name for name in bus_names if name]
|
||||
buses = []
|
||||
for name in bus_names:
|
||||
qs = Bus.objects.filter(name__iexact=name)
|
||||
if not qs.exists():
|
||||
raise ValueError(f"Bus '{name}' does not exist")
|
||||
buses.append(qs.get())
|
||||
|
||||
informations = {bus: CurrentSurvey.get_algorithm_class().get_bus_information(bus) for bus in buses}
|
||||
|
||||
for line in file:
|
||||
elem = line.split(';')
|
||||
word = elem[0]
|
||||
if word not in WORDS:
|
||||
raise ValueError(f"Word {word} is not used")
|
||||
|
||||
for i, bus in enumerate(buses):
|
||||
info = informations[bus]
|
||||
info.scores[word] = float(elem[i + 1].replace(',', '.'))
|
||||
|
||||
for bus, info in informations.items():
|
||||
info.save()
|
||||
bus.save()
|
||||
if options['verbosity'] > 0:
|
||||
self.stdout.write(f"Bus {bus.name} saved!")
|
@ -24,17 +24,31 @@ 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()
|
||||
per_bus = {bus: [r for r in registrations if r.information['selected_bus_pk'] == bus.pk]
|
||||
per_bus = {bus: [r for r in registrations if 'selected_bus_pk' in r.information
|
||||
and r.information['selected_bus_pk'] == bus.pk]
|
||||
for bus in algorithm.get_buses()}
|
||||
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']:
|
||||
|
@ -7,6 +7,7 @@ from datetime import date
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from member.models import Club, Membership
|
||||
@ -98,6 +99,13 @@ class Bus(models.Model):
|
||||
"""
|
||||
self.information_json = json.dumps(information, indent=2)
|
||||
|
||||
@property
|
||||
def suggested_first_year(self):
|
||||
registrations = WEIRegistration.objects.filter(Q(membership__isnull=True) | Q(membership__bus__isnull=True),
|
||||
first_year=True, wei=self.wei)
|
||||
registrations = [r for r in registrations if 'selected_bus_pk' in r.information]
|
||||
return sum(1 for r in registrations if r.information['selected_bus_pk'] == self.pk)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@ -364,8 +372,19 @@ class WEIMembership(Membership):
|
||||
# to treasurers.
|
||||
transaction.refresh_from_db()
|
||||
from treasury.models import SogeCredit
|
||||
soge_credit = SogeCredit.objects.get_or_create(user=self.user)[0]
|
||||
soge_credit, created = SogeCredit.objects.get_or_create(user=self.user)
|
||||
soge_credit.refresh_from_db()
|
||||
transaction.save()
|
||||
soge_credit.transactions.add(transaction)
|
||||
soge_credit.save()
|
||||
|
||||
soge_credit.update_transactions()
|
||||
soge_credit.save()
|
||||
|
||||
if soge_credit.valid and \
|
||||
soge_credit.credit_transaction.total != sum(tr.total for tr in soge_credit.transactions.all()):
|
||||
# The credit is already validated, but we add a new transaction (eg. for the WEI).
|
||||
# Then we invalidate the transaction, update the credit transaction amount
|
||||
# and re-validate the credit.
|
||||
soge_credit.validate(True)
|
||||
soge_credit.save()
|
||||
|
@ -4,6 +4,7 @@
|
||||
from datetime import date
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -99,9 +100,12 @@ class WEIRegistrationTable(tables.Table):
|
||||
|
||||
url = reverse_lazy('wei:validate_registration', args=(record.pk,))
|
||||
text = _('Validate')
|
||||
if record.fee > record.user.note.balance:
|
||||
if record.fee > record.user.note.balance and not record.soge_credit:
|
||||
btn_class = 'btn-secondary'
|
||||
tooltip = _("The user does not have enough money.")
|
||||
elif record.first_year:
|
||||
btn_class = 'btn-info'
|
||||
tooltip = _("The user is in first year. You may validate the credit, the algorithm will run later.")
|
||||
else:
|
||||
btn_class = 'btn-success'
|
||||
tooltip = _("The user has enough money, you can validate the registration.")
|
||||
@ -166,6 +170,35 @@ class WEIMembershipTable(tables.Table):
|
||||
}
|
||||
|
||||
|
||||
class WEIRegistration1ATable(tables.Table):
|
||||
user = tables.LinkColumn(
|
||||
'wei:wei_bus_1A',
|
||||
args=[A('pk')],
|
||||
)
|
||||
|
||||
preferred_bus = tables.Column(
|
||||
verbose_name=_('preferred bus').capitalize,
|
||||
accessor='pk',
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
def render_preferred_bus(self, record):
|
||||
information = record.information
|
||||
return information['selected_bus_name'] if 'selected_bus_name' in information else "—"
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
||||
model = WEIRegistration
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('user', 'user__last_name', 'user__first_name', 'gender',
|
||||
'user__profile__department', 'preferred_bus', 'membership__bus', )
|
||||
row_attrs = {
|
||||
'class': lambda record: '' if 'selected_bus_pk' in record.information else 'bg-danger',
|
||||
}
|
||||
|
||||
|
||||
class BusTable(tables.Table):
|
||||
name = tables.LinkColumn(
|
||||
'wei:manage_bus',
|
||||
@ -242,3 +275,66 @@ class BusTeamTable(tables.Table):
|
||||
'id': lambda record: "row-" + str(record.pk),
|
||||
'data-href': lambda record: reverse_lazy('wei:manage_bus_team', args=(record.pk, ))
|
||||
}
|
||||
|
||||
|
||||
class BusRepartitionTable(tables.Table):
|
||||
name = tables.Column(
|
||||
verbose_name=_("name").capitalize,
|
||||
accessor='name',
|
||||
)
|
||||
|
||||
suggested_first_year = tables.Column(
|
||||
verbose_name=_("suggested first year").capitalize,
|
||||
accessor='pk',
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
validated_first_year = tables.Column(
|
||||
verbose_name=_("validated first year").capitalize,
|
||||
accessor='pk',
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
validated_staff = tables.Column(
|
||||
verbose_name=_("validated staff").capitalize,
|
||||
accessor='pk',
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
size = tables.Column(
|
||||
verbose_name=_("seat count in the bus").capitalize,
|
||||
accessor='size',
|
||||
)
|
||||
|
||||
free_seats = tables.Column(
|
||||
verbose_name=_("free seats").capitalize,
|
||||
accessor='pk',
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
def render_suggested_first_year(self, record):
|
||||
registrations = WEIRegistration.objects.filter(Q(membership__isnull=True) | Q(membership__bus__isnull=True),
|
||||
first_year=True, wei=record.wei)
|
||||
registrations = [r for r in registrations if 'selected_bus_pk' in r.information]
|
||||
return sum(1 for r in registrations if r.information['selected_bus_pk'] == record.pk)
|
||||
|
||||
def render_validated_first_year(self, record):
|
||||
return WEIRegistration.objects.filter(first_year=True, membership__bus=record).count()
|
||||
|
||||
def render_validated_staff(self, record):
|
||||
return WEIRegistration.objects.filter(first_year=False, membership__bus=record).count()
|
||||
|
||||
def render_free_seats(self, record):
|
||||
return record.size - self.render_validated_staff(record) - self.render_validated_first_year(record)
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
||||
models = Bus
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('name', )
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'id': lambda record: "row-" + str(record.pk),
|
||||
}
|
||||
|
20
apps/wei/templates/wei/1A_list.html
Normal file
20
apps/wei/templates/wei/1A_list.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% extends "wei/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block profile_content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3>{% trans "Attribute first year members into buses" %}</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{% render_table bus_repartition_table %}
|
||||
<hr>
|
||||
<a href="{% url 'wei:wei_bus_1A_next' pk=club.pk %}" class="btn btn-block btn-success">{% trans "Start attribution!" %}</a>
|
||||
<hr>
|
||||
{% render_table table %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
88
apps/wei/templates/wei/attribute_bus_1A.html
Normal file
88
apps/wei/templates/wei/attribute_bus_1A.html
Normal file
@ -0,0 +1,88 @@
|
||||
{% extends "wei/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block profile_content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3>{% trans "Bus attribution" %}</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-xl-6">{% trans 'user'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'last name'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user.last_name }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'first name'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user.first_name }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'gender'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.get_gender_display }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'department'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user.profile.get_department_display }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'health issues'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.health_issues|default:"—" }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'suggested bus'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ survey.information.selected_bus_name }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<button class="btn btn-link" data-toggle="collapse" data-target="#raw-survey">{% trans "View raw survey information" %}</button>
|
||||
</div>
|
||||
<div class="collapse" id="raw-survey">
|
||||
<dl class="row">
|
||||
{% for key, value in survey.registration.information.items %}
|
||||
<dt class="col-xl-6">{{ key }}</dt>
|
||||
<dd class="col-xl-6">{{ value }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
{% for bus, score in survey.ordered_buses %}
|
||||
<button class="btn btn-{% if bus.pk == survey.information.selected_bus_pk %}success{% else %}light{% endif %}" onclick="choose_bus({{ bus.pk }})">
|
||||
{{ bus }} ({{ score|floatformat:2 }}) : {{ bus.memberships.count }}+{{ bus.suggested_first_year }} / {{ bus.size }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
<a href="{% url 'wei:wei_1A_list' pk=object.wei.pk %}" class="btn btn-block btn-info">{% trans "Back to main list" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
function choose_bus(bus_id) {
|
||||
let valid_buses = [{% for bus, score in survey.ordered_buses %}{{ bus.pk }}, {% endfor %}];
|
||||
if (valid_buses.indexOf(bus_id) === -1) {
|
||||
console.log("Invalid chosen bus")
|
||||
return
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: "/api/wei/membership/{{ object.membership.id }}/",
|
||||
type: "PATCH",
|
||||
dataType: "json",
|
||||
headers: {
|
||||
"X-CSRFTOKEN": CSRF_TOKEN
|
||||
},
|
||||
data: {
|
||||
bus: bus_id,
|
||||
}
|
||||
}).done(function () {
|
||||
window.location = "{% url 'wei:wei_bus_1A_next' pk=object.wei.pk %}"
|
||||
}).fail(function (xhr) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
@ -94,6 +94,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_validate_1a %}
|
||||
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
\usepackage{fontspec}
|
||||
\usepackage[margin=1.5cm]{geometry}
|
||||
\usepackage{longtable}
|
||||
|
||||
\begin{document}
|
||||
\begin{center}
|
||||
@ -19,7 +20,7 @@
|
||||
|
||||
\begin{center}
|
||||
\footnotesize
|
||||
\begin{tabular}{ccccccccc}
|
||||
\begin{longtable}{ccccccccc}
|
||||
\textbf{Nom} & \textbf{Prénom} & \textbf{Date de naissance} & \textbf{Genre} & \textbf{Section}
|
||||
& \textbf{Bus} & \textbf{Équipe} & \textbf{Rôles} \\
|
||||
{% for membership in memberships %}
|
||||
@ -27,20 +28,20 @@
|
||||
& {{ membership.registration.get_gender_display|safe }} & {{ membership.user.profile.section_generated|safe }} & {{ membership.bus.name|safe }}
|
||||
& {% if membership.team %}{{ membership.team.name|safe }}{% else %}--{% endif %} & {{ membership.roles.first|safe }} \\
|
||||
{% endfor %}
|
||||
\end{tabular}
|
||||
\end{longtable}
|
||||
\end{center}
|
||||
|
||||
\footnotesize
|
||||
Section = Année à l'ENS + code du département
|
||||
|
||||
\begin{center}
|
||||
\begin{tabular}{ccccccccc}
|
||||
\begin{longtable}{ccccccccc}
|
||||
\textbf{Code} & A0 & A1 & A2 & A'2 & A''2 & A3 & B1234 & B1 \\
|
||||
\textbf{Département} & Informatique & Maths & Physique & Physique appliquée & Chimie & Biologie & SAPHIRE & Mécanique \\
|
||||
\hline
|
||||
\textbf{Code} & B2 & B3 & B4 & C & D2 & D3 & E & EXT \\
|
||||
\textbf{Département} & Génie civil & Génie mécanique & EEA & Design & Éco-gestion & Sciences sociales & Anglais & Extérieur
|
||||
\end{tabular}
|
||||
\end{longtable}
|
||||
\end{center}
|
||||
|
||||
\end{document}
|
||||
|
@ -53,7 +53,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<dd class="col-xl-6">{{ registration.first_year|yesno }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'gender'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ registration.gender }}</dd>
|
||||
<dd class="col-xl-6">{{ registration.get_gender_display }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'clothing cut'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ registration.clothing_cut }}</dd>
|
||||
|
@ -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 = []
|
||||
|
110
apps/wei/tests/test_wei_algorithm_2022.py
Normal file
110
apps/wei/tests/test_wei_algorithm_2022.py
Normal 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 %
|
@ -12,7 +12,7 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from member.models import Membership, Club
|
||||
from note.models import NoteClub, SpecialTransaction
|
||||
from note.models import NoteClub, SpecialTransaction, NoteUser
|
||||
from treasury.models import SogeCredit
|
||||
|
||||
from ..api.views import BusViewSet, BusTeamViewSet, WEIClubViewSet, WEIMembershipViewSet, WEIRegistrationViewSet, \
|
||||
@ -84,6 +84,13 @@ class TestWEIRegistration(TestCase):
|
||||
wei=self.wei,
|
||||
description="Test Bus",
|
||||
)
|
||||
|
||||
# Setup the bus
|
||||
bus_info = CurrentSurvey.get_algorithm_class().get_bus_information(self.bus)
|
||||
bus_info.scores["Jus de fruit"] = 70
|
||||
bus_info.save()
|
||||
self.bus.save()
|
||||
|
||||
self.team = BusTeam.objects.create(
|
||||
name="Test Team",
|
||||
bus=self.bus,
|
||||
@ -295,6 +302,7 @@ class TestWEIRegistration(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user = User.objects.create(username="toto", email="toto@example.com")
|
||||
NoteUser.objects.create(user=user)
|
||||
|
||||
# Try with an invalid form
|
||||
response = self.client.post(reverse("wei:wei_register_2A", kwargs=dict(wei_pk=self.wei.pk)), dict(
|
||||
@ -361,7 +369,7 @@ class TestWEIRegistration(TestCase):
|
||||
last_name="toto",
|
||||
bank="Société générale",
|
||||
))
|
||||
response = self.client.get(reverse("wei:wei_register_2A_myself", kwargs=dict(wei_pk=self.wei.pk)))
|
||||
response = self.client.get(reverse("wei:wei_register_2A", kwargs=dict(wei_pk=self.wei.pk)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that if the WEI is started, we can't register anyone
|
||||
@ -377,10 +385,8 @@ class TestWEIRegistration(TestCase):
|
||||
response = self.client.get(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse("wei:wei_register_1A_myself", kwargs=dict(wei_pk=self.wei.pk)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user = User.objects.create(username="toto", email="toto@example.com")
|
||||
NoteUser.objects.create(user=user)
|
||||
response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict(
|
||||
user=user.id,
|
||||
soge_credit=True,
|
||||
@ -460,6 +466,24 @@ class TestWEIRegistration(TestCase):
|
||||
response = self.client.get(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)))
|
||||
self.assertRedirects(response, reverse("wei:wei_closed", kwargs=dict(pk=self.wei.pk)), 302, 200)
|
||||
|
||||
def test_register_myself(self):
|
||||
"""
|
||||
Try to register myself to the WEI, and check redirections.
|
||||
"""
|
||||
response = self.client.get(reverse('wei:wei_register_1A_myself', args=(self.wei.pk,)))
|
||||
self.assertRedirects(response, reverse('wei:wei_update_registration', args=(self.registration.pk,)))
|
||||
|
||||
response = self.client.get(reverse('wei:wei_register_2A_myself', args=(self.wei.pk,)))
|
||||
self.assertRedirects(response, reverse('wei:wei_update_registration', args=(self.registration.pk,)))
|
||||
|
||||
self.registration.delete()
|
||||
|
||||
response = self.client.get(reverse('wei:wei_register_1A_myself', args=(self.wei.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse('wei:wei_register_2A_myself', args=(self.wei.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_wei_survey_ended(self):
|
||||
"""
|
||||
Test display the end page of a survey.
|
||||
@ -758,59 +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)
|
||||
|
||||
|
||||
class TestWEISurveyAlgorithm(TestCase):
|
||||
"""
|
||||
Run the WEI Algorithm.
|
||||
TODO: Improve this test with some test data once the algorithm will be implemented.
|
||||
"""
|
||||
fixtures = ("initial",)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.year = timezone.now().year
|
||||
self.wei = WEIClub.objects.create(
|
||||
name="Test WEI",
|
||||
email="gc.wei@example.com",
|
||||
parent_club_id=2,
|
||||
membership_fee_paid=12500,
|
||||
membership_fee_unpaid=5500,
|
||||
membership_start=date(self.year, 1, 1),
|
||||
membership_end=date(self.year, 12, 31),
|
||||
year=self.year,
|
||||
date_start=date.today() + timedelta(days=2),
|
||||
date_end=date(self.year, 12, 31),
|
||||
)
|
||||
NoteClub.objects.create(club=self.wei)
|
||||
self.bus = Bus.objects.create(
|
||||
name="Test Bus",
|
||||
wei=self.wei,
|
||||
description="Test Bus",
|
||||
)
|
||||
self.team = BusTeam.objects.create(
|
||||
name="Test Team",
|
||||
bus=self.bus,
|
||||
color=0xFFFFFF,
|
||||
description="Test Team",
|
||||
)
|
||||
|
||||
self.user = User.objects.create(username="toto")
|
||||
self.registration = WEIRegistration.objects.create(
|
||||
user_id=self.user.id,
|
||||
wei_id=self.wei.id,
|
||||
soge_credit=True,
|
||||
caution_check=True,
|
||||
birth_date=date(2000, 1, 1),
|
||||
gender="nonbinary",
|
||||
clothing_cut="male",
|
||||
clothing_size="XL",
|
||||
health_issues="I am a bot",
|
||||
emergency_contact_name="Pikachu",
|
||||
emergency_contact_phone="+33123456789",
|
||||
first_year=True,
|
||||
)
|
||||
CurrentSurvey(self.registration).save()
|
||||
self.assertEqual(CurrentSurvey.get_year(), 2022)
|
||||
|
||||
|
||||
class TestWeiAPI(TestAPI):
|
||||
|
@ -3,12 +3,11 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import CurrentWEIDetailView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView,\
|
||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView,\
|
||||
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView,\
|
||||
WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, WEIDeleteRegistrationView,\
|
||||
WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
|
||||
|
||||
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
|
||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
|
||||
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
|
||||
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
|
||||
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
|
||||
|
||||
app_name = 'wei'
|
||||
urlpatterns = [
|
||||
@ -24,6 +23,7 @@ urlpatterns = [
|
||||
name="wei_memberships_bus_pdf"),
|
||||
path('detail/<int:wei_pk>/memberships/pdf/<int:bus_pk>/<int:team_pk>/', MemberListRenderView.as_view(),
|
||||
name="wei_memberships_team_pdf"),
|
||||
path('bus-1A/list/<int:pk>/', WEI1AListView.as_view(), name="wei_1A_list"),
|
||||
path('add-bus/<int:pk>/', BusCreateView.as_view(), name="add_bus"),
|
||||
path('manage-bus/<int:pk>/', BusManageView.as_view(), name="manage_bus"),
|
||||
path('update-bus/<int:pk>/', BusUpdateView.as_view(), name="update_bus"),
|
||||
@ -40,4 +40,6 @@ urlpatterns = [
|
||||
path('survey/<int:pk>/', WEISurveyView.as_view(), name="wei_survey"),
|
||||
path('survey/<int:pk>/end/', WEISurveyEndView.as_view(), name="wei_survey_end"),
|
||||
path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"),
|
||||
path('bus-1A/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
|
||||
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
|
||||
]
|
||||
|
@ -7,14 +7,14 @@ import subprocess
|
||||
from datetime import date, timedelta
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, Count
|
||||
from django.db.models.functions.text import Lower
|
||||
from django.forms import HiddenInput
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse_lazy
|
||||
@ -32,8 +32,10 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
|
||||
from .forms.registration import WEIChooseBusForm
|
||||
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
|
||||
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembershipForm, CurrentSurvey
|
||||
from .tables import WEITable, WEIRegistrationTable, BusTable, BusTeamTable, WEIMembershipTable
|
||||
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \
|
||||
WEIMembershipForm, CurrentSurvey
|
||||
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
|
||||
WEIRegistration1ATable, WEIMembershipTable
|
||||
|
||||
|
||||
class CurrentWEIDetailView(LoginRequiredMixin, RedirectView):
|
||||
@ -132,7 +134,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
wei=club
|
||||
)
|
||||
pre_registrations_table = WEIRegistrationTable(data=pre_registrations, prefix="pre-registration-")
|
||||
pre_registrations_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1))
|
||||
pre_registrations_table.paginate(per_page=20, page=self.request.GET.get('pre-registration-page', 1))
|
||||
context['pre_registrations'] = pre_registrations_table
|
||||
|
||||
my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user)
|
||||
@ -190,6 +192,10 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
|
||||
context["not_first_year"] = WEIMembership.objects.filter(user=self.request.user).exists()
|
||||
|
||||
qs = WEIMembership.objects.filter(club=club, registration__first_year=True, bus__isnull=True)
|
||||
context["can_validate_1a"] = PermissionBackend.check_perm(
|
||||
self.request, "wei.change_weimembership_bus", qs.first()) if qs.exists() else False
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@ -487,9 +493,16 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
def get_sample_object(self):
|
||||
wei = WEIClub.objects.get(pk=self.kwargs["wei_pk"])
|
||||
if "myself" in self.request.path:
|
||||
user = self.request.user
|
||||
else:
|
||||
# To avoid unique validation issues, we use an account that can't join the WEI.
|
||||
# In development mode, the note account may not exist, we use a random user (may fail)
|
||||
user = User.objects.get(username="note") \
|
||||
if User.objects.filter(username="note").exists() else User.objects.first()
|
||||
return WEIRegistration(
|
||||
wei=wei,
|
||||
user=self.request.user,
|
||||
user=user,
|
||||
first_year=True,
|
||||
birth_date="1970-01-01",
|
||||
gender="No",
|
||||
@ -503,6 +516,11 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
# We can't register someone once the WEI is started and before the membership start date
|
||||
if today >= wei.date_start or today < wei.membership_start:
|
||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||
# Don't register twice
|
||||
if 'myself' in self.request.path and not self.request.user.is_anonymous \
|
||||
and WEIRegistration.objects.filter(wei=wei, user=self.request.user).exists():
|
||||
obj = WEIRegistration.objects.get(wei=wei, user=self.request.user)
|
||||
return redirect(reverse_lazy('wei:wei_update_registration', args=(obj.pk,)))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -538,6 +556,12 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
" participated to a WEI."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
if 'treasury' in settings.INSTALLED_APPS:
|
||||
from treasury.models import SogeCredit
|
||||
form.instance.soge_credit = \
|
||||
form.instance.soge_credit \
|
||||
or SogeCredit.objects.filter(user=form.instance.user, credit_transaction__valid=False).exists()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
@ -555,9 +579,16 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
def get_sample_object(self):
|
||||
wei = WEIClub.objects.get(pk=self.kwargs["wei_pk"])
|
||||
if "myself" in self.request.path:
|
||||
user = self.request.user
|
||||
else:
|
||||
# To avoid unique validation issues, we use an account that can't join the WEI.
|
||||
# In development mode, the note account may not exist, we use a random user (may fail)
|
||||
user = User.objects.get(username="note") \
|
||||
if User.objects.filter(username="note").exists() else User.objects.first()
|
||||
return WEIRegistration(
|
||||
wei=wei,
|
||||
user=self.request.user,
|
||||
user=user,
|
||||
first_year=True,
|
||||
birth_date="1970-01-01",
|
||||
gender="No",
|
||||
@ -571,6 +602,11 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
# We can't register someone once the WEI is started and before the membership start date
|
||||
if today >= wei.date_start or today < wei.membership_start:
|
||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||
# Don't register twice
|
||||
if 'myself' in self.request.path and not self.request.user.is_anonymous \
|
||||
and WEIRegistration.objects.filter(wei=wei, user=self.request.user).exists():
|
||||
obj = WEIRegistration.objects.get(wei=wei, user=self.request.user)
|
||||
return redirect(reverse_lazy('wei:wei_update_registration', args=(obj.pk,)))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -627,6 +663,12 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
form.instance.information = information
|
||||
form.instance.save()
|
||||
|
||||
if 'treasury' in settings.INSTALLED_APPS:
|
||||
from treasury.models import SogeCredit
|
||||
form.instance.soge_credit = \
|
||||
form.instance.soge_credit \
|
||||
or SogeCredit.objects.filter(user=form.instance.user, credit_transaction__valid=False).exists()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
@ -655,26 +697,19 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
context["club"] = self.object.wei
|
||||
|
||||
if self.object.is_validated:
|
||||
membership_form = WEIMembershipForm(instance=self.object.membership,
|
||||
data=self.request.POST if self.request.POST else None)
|
||||
for field_name, field in membership_form.fields.items():
|
||||
if not PermissionBackend.check_perm(
|
||||
self.request, "wei.change_membership_" + field_name, self.object.membership):
|
||||
field.widget = HiddenInput()
|
||||
del membership_form.fields["credit_type"]
|
||||
del membership_form.fields["credit_amount"]
|
||||
del membership_form.fields["first_name"]
|
||||
del membership_form.fields["last_name"]
|
||||
del membership_form.fields["bank"]
|
||||
membership_form = self.get_membership_form(instance=self.object.membership,
|
||||
data=self.request.POST)
|
||||
context["membership_form"] = membership_form
|
||||
elif not self.object.first_year and PermissionBackend.check_perm(
|
||||
self.request, "wei.change_weiregistration_information_json", self.object):
|
||||
information = self.object.information
|
||||
d = dict(
|
||||
bus=Bus.objects.filter(pk__in=information["preferred_bus_pk"]).all(),
|
||||
team=BusTeam.objects.filter(pk__in=information["preferred_team_pk"]).all(),
|
||||
roles=WEIRole.objects.filter(pk__in=information["preferred_roles_pk"]).all(),
|
||||
) if 'preferred_bus_pk' in information else dict()
|
||||
choose_bus_form = WEIChooseBusForm(
|
||||
self.request.POST if self.request.POST else dict(
|
||||
bus=Bus.objects.filter(pk__in=self.object.information["preferred_bus_pk"]).all(),
|
||||
team=BusTeam.objects.filter(pk__in=self.object.information["preferred_team_pk"]).all(),
|
||||
roles=WEIRole.objects.filter(pk__in=self.object.information["preferred_roles_pk"]).all(),
|
||||
)
|
||||
self.request.POST if self.request.POST else d
|
||||
)
|
||||
choose_bus_form.fields["bus"].queryset = Bus.objects.filter(wei=context["club"])
|
||||
choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"])
|
||||
@ -690,15 +725,29 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.fields["user"].disabled = True
|
||||
if not self.object.first_year:
|
||||
# The auto-json-format may cause issues with the default field remove
|
||||
if not PermissionBackend.check_perm(self.request, 'wei.change_weiregistration_information_json', self.object):
|
||||
del form.fields["information_json"]
|
||||
return form
|
||||
|
||||
def get_membership_form(self, data=None, instance=None):
|
||||
membership_form = WEIMembershipForm(data if data else None, instance=instance)
|
||||
del membership_form.fields["credit_type"]
|
||||
del membership_form.fields["credit_amount"]
|
||||
del membership_form.fields["first_name"]
|
||||
del membership_form.fields["last_name"]
|
||||
del membership_form.fields["bank"]
|
||||
for field_name, _field in list(membership_form.fields.items()):
|
||||
if not PermissionBackend.check_perm(
|
||||
self.request, "wei.change_weimembership_" + field_name, self.object.membership):
|
||||
del membership_form.fields[field_name]
|
||||
return membership_form
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
# If the membership is already validated, then we update the bus and the team (and the roles)
|
||||
if form.instance.is_validated:
|
||||
membership_form = WEIMembershipForm(self.request.POST, instance=form.instance.membership)
|
||||
membership_form = self.get_membership_form(self.request.POST, form.instance.membership)
|
||||
if not membership_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
membership_form.save()
|
||||
@ -772,7 +821,6 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
Validate WEI Registration
|
||||
"""
|
||||
model = WEIMembership
|
||||
form_class = WEIMembershipForm
|
||||
extra_context = {"title": _("Validate WEI registration")}
|
||||
|
||||
def get_sample_object(self):
|
||||
@ -828,6 +876,12 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
return context
|
||||
|
||||
def get_form_class(self):
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
if registration.first_year and 'sleected_bus_pk' not in registration.information:
|
||||
return WEIMembership1AForm
|
||||
return WEIMembershipForm
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
@ -843,25 +897,27 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
form.fields["bank"].disabled = True
|
||||
form.fields["bank"].initial = "Société générale"
|
||||
|
||||
form.fields["bus"].widget.attrs["api_url"] = "/api/wei/bus/?wei=" + str(registration.wei.pk)
|
||||
if registration.first_year:
|
||||
# Use the results of the survey to fill initial data
|
||||
# A first year has no other role than "1A"
|
||||
del form.fields["roles"]
|
||||
survey = CurrentSurvey(registration)
|
||||
if survey.information.valid:
|
||||
form.fields["bus"].initial = survey.information.get_selected_bus()
|
||||
else:
|
||||
# Use the choice of the member to fill initial data
|
||||
information = registration.information
|
||||
if "preferred_bus_pk" in information and len(information["preferred_bus_pk"]) == 1:
|
||||
form["bus"].initial = Bus.objects.get(pk=information["preferred_bus_pk"][0])
|
||||
if "preferred_team_pk" in information and len(information["preferred_team_pk"]) == 1:
|
||||
form["team"].initial = BusTeam.objects.get(pk=information["preferred_team_pk"][0])
|
||||
if "preferred_roles_pk" in information:
|
||||
form["roles"].initial = WEIRole.objects.filter(
|
||||
Q(pk__in=information["preferred_roles_pk"]) | Q(name="Adhérent WEI")
|
||||
).all()
|
||||
if 'bus' in form.fields:
|
||||
# For 2A+ and hardcoded 1A
|
||||
form.fields["bus"].widget.attrs["api_url"] = "/api/wei/bus/?wei=" + str(registration.wei.pk)
|
||||
if registration.first_year:
|
||||
# Use the results of the survey to fill initial data
|
||||
# A first year has no other role than "1A"
|
||||
del form.fields["roles"]
|
||||
survey = CurrentSurvey(registration)
|
||||
if survey.information.valid:
|
||||
form.fields["bus"].initial = survey.information.get_selected_bus()
|
||||
else:
|
||||
# Use the choice of the member to fill initial data
|
||||
information = registration.information
|
||||
if "preferred_bus_pk" in information and len(information["preferred_bus_pk"]) == 1:
|
||||
form["bus"].initial = Bus.objects.get(pk=information["preferred_bus_pk"][0])
|
||||
if "preferred_team_pk" in information and len(information["preferred_team_pk"]) == 1:
|
||||
form["team"].initial = BusTeam.objects.get(pk=information["preferred_team_pk"][0])
|
||||
if "preferred_roles_pk" in information:
|
||||
form["roles"].initial = WEIRole.objects.filter(
|
||||
Q(pk__in=information["preferred_roles_pk"]) | Q(name="Adhérent WEI")
|
||||
).all()
|
||||
return form
|
||||
|
||||
@transaction.atomic
|
||||
@ -950,12 +1006,11 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
membership.roles.set(WEIRole.objects.filter(name="1A").all())
|
||||
membership.save()
|
||||
|
||||
ret = super().form_valid(form)
|
||||
|
||||
membership.save()
|
||||
membership.refresh_from_db()
|
||||
membership.roles.add(WEIRole.objects.get(name="Adhérent WEI"))
|
||||
|
||||
return ret
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
self.object.refresh_from_db()
|
||||
@ -1122,3 +1177,65 @@ class MemberListRenderView(LoginRequiredMixin, View):
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WEI1AListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableView):
|
||||
model = WEIRegistration
|
||||
template_name = "wei/1A_list.html"
|
||||
table_class = WEIRegistration1ATable
|
||||
extra_context = {"title": _("Attribute buses to first year members")}
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.club = WEIClub.objects.get(pk=self.kwargs["pk"])
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self, filter_permissions=True, **kwargs):
|
||||
qs = super().get_queryset(filter_permissions, **kwargs)
|
||||
qs = qs.filter(first_year=True, membership__isnull=False)
|
||||
qs = qs.order_by('-membership__bus')
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['club'] = self.club
|
||||
context['bus_repartition_table'] = BusRepartitionTable(
|
||||
Bus.objects.filter(wei=self.club, size__gt=0)
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Bus, "view"))
|
||||
.all())
|
||||
return context
|
||||
|
||||
|
||||
class WEIAttributeBus1AView(ProtectQuerysetMixin, DetailView):
|
||||
model = WEIRegistration
|
||||
template_name = "wei/attribute_bus_1A.html"
|
||||
extra_context = {"title": _("Attribute bus")}
|
||||
|
||||
def get_queryset(self, filter_permissions=True, **kwargs):
|
||||
qs = super().get_queryset(filter_permissions, **kwargs)
|
||||
qs = qs.filter(first_year=True)
|
||||
return qs
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if 'selected_bus_pk' not in obj.information:
|
||||
return redirect(reverse_lazy('wei:wei_survey', args=(obj.pk,)))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['club'] = self.object.wei
|
||||
context['survey'] = CurrentSurvey(self.object)
|
||||
return context
|
||||
|
||||
|
||||
class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
wei = WEIClub.objects.filter(pk=self.kwargs['pk'])
|
||||
if not wei.exists():
|
||||
raise Http404
|
||||
wei = wei.get()
|
||||
qs = WEIRegistration.objects.filter(wei=wei, membership__isnull=False, membership__bus__isnull=True)
|
||||
qs = qs.filter(information_json__contains='selected_bus_pk') # not perfect, but works...
|
||||
if qs.exists():
|
||||
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk, ))
|
||||
return reverse_lazy('wei:wei_1A_list', args=(wei.pk, ))
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
---------------------------------------------------------------------------------------------------
|
||||
|
@ -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
@ -75,7 +75,7 @@ class LoginByIPMiddleware(object):
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
qs = User.objects.filter(password=f"ipbased${ip}")
|
||||
qs = User.objects.filter(password__iregex=f"ipbased\\$.*\\^{ip}\\$.*")
|
||||
if qs.exists():
|
||||
login(request, qs.get())
|
||||
session = request.session
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
6
note_kfet/static/js/turbolinks.js
Normal file
6
note_kfet/static/js/turbolinks.js
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
||||
@ -170,8 +169,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% if user.sogecredit and not user.sogecredit.valid %}
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
You declared that you opened a bank account in the Société générale. The bank did not validate the creation of the account to the BDE,
|
||||
so the registration bonus of 80 € is not credited and the membership is not paid yet.
|
||||
You declared that you opened a bank account in the Société générale. The bank did not validate
|
||||
the creation of the account to the BDE, so the membership and the WEI are not paid yet.
|
||||
This verification procedure may last a few days.
|
||||
Please make sure that you go to the end of the account creation.
|
||||
{% endblocktrans %}
|
||||
@ -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> —
|
||||
<a href="mailto:{{ "SUPPORT_EMAIL" | getenv }}"
|
||||
class="text-muted">{% trans "Technical Support" %}</a> —
|
||||
</span>
|
||||
{% csrf_token %}
|
||||
<select title="language" name="language"
|
||||
|
@ -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 %}
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user