mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-07-04 14:52:13 +02:00
Compare commits
188 Commits
802a6c68cb
...
inclusive
Author | SHA1 | Date | |
---|---|---|---|
5c77b6ebfa
|
|||
92610c5ffd
|
|||
ddbdd5b5d0
|
|||
e3ee39ca81
|
|||
0c1c845f72
|
|||
edcf42beb6
|
|||
3306aed6dc
|
|||
a69573ccdb
|
|||
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 | |||
9930c48253 | |||
d902e63a0c
|
|||
48b0bade51
|
|||
f75dbc4525
|
|||
fbf64db16e
|
|||
a3fd8ba063
|
|||
9b26207515
|
|||
7ea36a5415
|
|||
898f6d52bf
|
|||
8be16e7b58
|
|||
ea092803d7
|
|||
5e9f36ef1a
|
|||
b4d87bc6b5
|
|||
dd639d829e
|
|||
7b809ff3a6 | |||
d36edfc063
|
|||
cf87da096f
|
|||
e452b7acbf
|
|||
74ab4df9fe
|
|||
451851c955
|
|||
789ca149af | |||
7d3f1930b8 | |||
e8f4ca1e09
|
|||
733f145be3
|
|||
48c37353ea
|
|||
8056dc096d
|
|||
6d5b69cd26
|
|||
a7bdffd71a
|
|||
0887e4bbde
|
|||
199f4ca1f2
|
|||
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
|
||||
|
||||
@ -55,7 +55,7 @@ Bien que cela permette de créer une instance sur toutes les distributions,
|
||||
(env)$ ./manage.py makemigrations
|
||||
(env)$ ./manage.py migrate
|
||||
(env)$ ./manage.py loaddata initial
|
||||
(env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
|
||||
(env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
|
||||
```
|
||||
|
||||
6. Enjoy :
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club
|
||||
from note.models import Note, NoteUser
|
||||
from note_kfet.inputs import Autocomplete, DateTimePickerInput
|
||||
from note_kfet.middlewares import get_current_authenticated_user
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models import Activity, Guest
|
||||
@ -24,9 +24,15 @@ class ActivityForm(forms.ModelForm):
|
||||
self.fields["attendees_club"].initial = Club.objects.get(name="Kfet")
|
||||
self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet"
|
||||
clubs = list(Club.objects.filter(PermissionBackend
|
||||
.filter_queryset(get_current_authenticated_user(), Club, "view")).all())
|
||||
.filter_queryset(get_current_request(), Club, "view")).all())
|
||||
shuffle(clubs)
|
||||
self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||
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"]
|
||||
@ -47,7 +53,7 @@ class ActivityForm(forms.ModelForm):
|
||||
model=Note,
|
||||
attrs={
|
||||
"api_url": "/api/note/note/",
|
||||
'placeholder': 'Note de l\'événement sur laquelle envoyer les crédits d\'invitation ...'
|
||||
'placeholder': 'Note de l\'événement sur laquelle envoyer les crédits d\'invitation…'
|
||||
},
|
||||
),
|
||||
"attendees_club": Autocomplete(
|
||||
@ -109,7 +115,7 @@ class GuestForm(forms.ModelForm):
|
||||
# We don't evaluate the content type at launch because the DB might be not initialized
|
||||
'api_url_suffix':
|
||||
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteUser).pk),
|
||||
'placeholder': 'Note ...',
|
||||
'placeholder': 'Note…',
|
||||
},
|
||||
),
|
||||
}
|
||||
|
@ -126,7 +126,7 @@ class Activity(models.Model):
|
||||
@transaction.atomic
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Update the activity wiki page each time the activity is updated (validation, change description, ...)
|
||||
Update the activity wiki page each time the activity is updated (validation, change description,…)
|
||||
"""
|
||||
if self.date_end < self.date_start:
|
||||
raise ValidationError(_("The end date must be after the start date."))
|
||||
|
@ -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,7 +54,7 @@ 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)"> '
|
||||
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()))
|
||||
|
||||
|
||||
@ -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):
|
||||
|
@ -37,7 +37,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<button class="btn btn-light">{% trans "Return to activity page" %}</button>
|
||||
</a>
|
||||
|
||||
<input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
|
||||
<input id="alias" type="text" class="form-control" placeholder="Nom/note…">
|
||||
|
||||
<hr>
|
||||
|
||||
@ -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,21 +66,19 @@ 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)
|
||||
|
||||
upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
|
||||
context['upcoming'] = ActivityTable(
|
||||
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")),
|
||||
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")),
|
||||
prefix='upcoming-',
|
||||
)
|
||||
|
||||
started_activities = Activity.objects\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, 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
|
||||
@ -98,7 +96,7 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
context = super().get_context_data()
|
||||
|
||||
table = GuestTable(data=Guest.objects.filter(activity=self.object)
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
|
||||
context["guests"] = table
|
||||
|
||||
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
|
||||
@ -144,15 +142,15 @@ 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.user, Activity, "view"))\
|
||||
.get(pk=self.kwargs["pk"])
|
||||
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||
.filter(pk=self.kwargs["pk"]).first()
|
||||
form.fields["inviter"].initial = self.request.user.note
|
||||
return form
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.activity = Activity.objects\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"])
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"])
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
@ -170,10 +168,13 @@ 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)
|
||||
if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry):
|
||||
if not PermissionBackend.check_perm(self.request, "activity.add_entry", sample_entry):
|
||||
raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
|
||||
|
||||
if not activity.activity_type.manage_entries:
|
||||
@ -191,8 +192,8 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
guest_qs = Guest.objects\
|
||||
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
|
||||
.filter(activity=activity)\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
|
||||
.order_by('last_name', 'first_name').distinct()
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
|
||||
.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):
|
||||
"""
|
||||
@ -230,7 +231,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
)
|
||||
|
||||
# Filter with permission backend
|
||||
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
|
||||
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view"))
|
||||
|
||||
if "search" in self.request.GET and self.request.GET["search"]:
|
||||
pattern = self.request.GET["search"]
|
||||
@ -256,7 +257,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
|
||||
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||
.distinct().get(pk=self.kwargs["pk"])
|
||||
context["activity"] = activity
|
||||
|
||||
@ -281,9 +282,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
|
||||
|
||||
activities_open = Activity.objects.filter(open=True).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all()
|
||||
PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
|
||||
context["activities_open"] = [a for a in activities_open
|
||||
if PermissionBackend.check_perm(self.request.user,
|
||||
if PermissionBackend.check_perm(self.request,
|
||||
"activity.add_entry",
|
||||
Entry(activity=a, note=self.request.user.note,))]
|
||||
|
||||
|
@ -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
|
||||
|
@ -9,7 +9,6 @@ from django.contrib.auth.models import User
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
|
||||
from permission.backends import PermissionBackend
|
||||
from note_kfet.middlewares import get_current_session
|
||||
from note.models import Alias
|
||||
|
||||
from .serializers import UserSerializer, ContentTypeSerializer
|
||||
@ -25,9 +24,7 @@ class ReadProtectedModelViewSet(ModelViewSet):
|
||||
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
get_current_session().setdefault("permission_mask", 42)
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
|
||||
|
||||
|
||||
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
|
||||
@ -40,9 +37,7 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
|
||||
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
get_current_session().setdefault("permission_mask", 42)
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
|
||||
|
||||
|
||||
class UserViewSet(ReadProtectedModelViewSet):
|
||||
|
@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from note.models import NoteUser, Alias
|
||||
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
from .models import Changelog
|
||||
|
||||
@ -56,13 +56,13 @@ def save_object(sender, instance, **kwargs):
|
||||
# noinspection PyProtectedMember
|
||||
previous = instance._previous
|
||||
|
||||
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
|
||||
user, ip = get_current_authenticated_user(), get_current_ip()
|
||||
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
|
||||
request = get_current_request()
|
||||
|
||||
if user is None:
|
||||
if request is None:
|
||||
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
|
||||
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
|
||||
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
|
||||
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
|
||||
ip = "127.0.0.1"
|
||||
username = Alias.normalize(getpass.getuser())
|
||||
note = NoteUser.objects.filter(alias__normalized_name=username)
|
||||
@ -71,9 +71,23 @@ def save_object(sender, instance, **kwargs):
|
||||
# else:
|
||||
if note.exists():
|
||||
user = note.get().user
|
||||
else:
|
||||
user = None
|
||||
else:
|
||||
user = request.user
|
||||
if 'HTTP_X_REAL_IP' in request.META:
|
||||
ip = request.META.get('HTTP_X_REAL_IP')
|
||||
elif 'HTTP_X_FORWARDED_FOR' in request.META:
|
||||
ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if not user.is_authenticated:
|
||||
# For registration and OAuth2 purposes
|
||||
user = None
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
if user is not None and instance._meta.label_lower == "auth.user" and previous:
|
||||
if request is not None and instance._meta.label_lower == "auth.user" and previous:
|
||||
# On n'enregistre pas les connexions
|
||||
if instance.last_login != previous.last_login:
|
||||
return
|
||||
@ -120,13 +134,13 @@ def delete_object(sender, instance, **kwargs):
|
||||
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
|
||||
return
|
||||
|
||||
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
|
||||
user, ip = get_current_authenticated_user(), get_current_ip()
|
||||
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
|
||||
request = get_current_request()
|
||||
|
||||
if user is None:
|
||||
if request is None:
|
||||
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
|
||||
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
|
||||
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
|
||||
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
|
||||
ip = "127.0.0.1"
|
||||
username = Alias.normalize(getpass.getuser())
|
||||
note = NoteUser.objects.filter(alias__normalized_name=username)
|
||||
@ -135,6 +149,20 @@ def delete_object(sender, instance, **kwargs):
|
||||
# else:
|
||||
if note.exists():
|
||||
user = note.get().user
|
||||
else:
|
||||
user = None
|
||||
else:
|
||||
user = request.user
|
||||
if 'HTTP_X_REAL_IP' in request.META:
|
||||
ip = request.META.get('HTTP_X_REAL_IP')
|
||||
elif 'HTTP_X_FORWARDED_FOR' in request.META:
|
||||
ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if not user.is_authenticated:
|
||||
# For registration and OAuth2 purposes
|
||||
user = None
|
||||
|
||||
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
|
||||
class CustomSerializer(ModelSerializer):
|
||||
|
@ -200,9 +200,9 @@ class MembershipForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ('user', 'date_start')
|
||||
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
|
||||
# Le champ d'utilisateur⋅rice est remplacé par un champ d'auto-complétion.
|
||||
# Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion
|
||||
# et récupère les noms d'utilisateur valides
|
||||
# et récupère les noms d'utilisateur⋅rices valides
|
||||
widgets = {
|
||||
'user':
|
||||
Autocomplete(
|
||||
@ -210,7 +210,7 @@ class MembershipForm(forms.ModelForm):
|
||||
attrs={
|
||||
'api_url': '/api/user/',
|
||||
'name_field': 'username',
|
||||
'placeholder': 'Nom ...',
|
||||
'placeholder': 'Nom…',
|
||||
},
|
||||
),
|
||||
'date_start': DatePickerInput(),
|
||||
@ -227,7 +227,7 @@ class MembershipRolesForm(forms.ModelForm):
|
||||
attrs={
|
||||
'api_url': '/api/user/',
|
||||
'name_field': 'username',
|
||||
'placeholder': 'Nom ...',
|
||||
'placeholder': 'Nom…',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
@ -2,11 +2,13 @@
|
||||
# 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 note_kfet.middlewares import get_current_authenticated_user, get_current_session
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
|
||||
class CustomNK15Hasher(PBKDF2PasswordHasher):
|
||||
@ -24,16 +26,22 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
|
||||
|
||||
def must_update(self, encoded):
|
||||
if settings.DEBUG:
|
||||
current_user = get_current_authenticated_user()
|
||||
# Small hack to let superusers to impersonate people.
|
||||
# Don't change their password.
|
||||
request = get_current_request()
|
||||
current_user = request.user
|
||||
if current_user is not None and current_user.is_superuser:
|
||||
return False
|
||||
return True
|
||||
|
||||
def verify(self, password, encoded):
|
||||
if settings.DEBUG:
|
||||
current_user = get_current_authenticated_user()
|
||||
# Small hack to let superusers to impersonate people.
|
||||
# If a superuser is already connected, let him/her log in as another person.
|
||||
request = get_current_request()
|
||||
current_user = request.user
|
||||
if current_user is not None and current_user.is_superuser\
|
||||
and get_current_session().get("permission_mask", -1) >= 42:
|
||||
and request.session.get("permission_mask", -1) >= 42:
|
||||
return True
|
||||
|
||||
if '|' in encoded:
|
||||
@ -41,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):
|
||||
"""
|
||||
@ -51,8 +71,11 @@ class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
|
||||
|
||||
def verify(self, password, encoded):
|
||||
if settings.DEBUG:
|
||||
current_user = get_current_authenticated_user()
|
||||
# Small hack to let superusers to impersonate people.
|
||||
# If a superuser is already connected, let him/her log in as another person.
|
||||
request = get_current_request()
|
||||
current_user = request.user
|
||||
if current_user is not None and current_user.is_superuser\
|
||||
and get_current_session().get("permission_mask", -1) >= 42:
|
||||
and request.session.get("permission_mask", -1) >= 42:
|
||||
return True
|
||||
return super().verify(password, encoded)
|
||||
|
@ -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,14 +258,16 @@ 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:
|
||||
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
|
||||
@ -400,10 +402,10 @@ class Membership(models.Model):
|
||||
|
||||
if self.club.parent_club.name == "BDE":
|
||||
parent_membership.roles.set(
|
||||
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
|
||||
Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all())
|
||||
elif self.club.parent_club.name == "Kfet":
|
||||
parent_membership.roles.set(
|
||||
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
|
||||
Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
|
||||
else:
|
||||
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
|
||||
parent_membership.save()
|
||||
@ -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)
|
||||
})
|
@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.html import format_html
|
||||
from note.templatetags.pretty_money import pretty_money
|
||||
from note_kfet.middlewares import get_current_authenticated_user
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models import Club, Membership
|
||||
@ -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',
|
||||
}
|
||||
|
||||
|
||||
@ -51,19 +52,19 @@ class UserTable(tables.Table):
|
||||
def render_email(self, record, value):
|
||||
# Replace the email by a dash if the user can't see the profile detail
|
||||
# Replace also the URL
|
||||
if not PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_profile", record.profile):
|
||||
if not PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile):
|
||||
value = "—"
|
||||
record.email = value
|
||||
return value
|
||||
|
||||
def render_section(self, record, value):
|
||||
return value \
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_profile", record.profile) \
|
||||
if PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile) \
|
||||
else "—"
|
||||
|
||||
def render_balance(self, record, value):
|
||||
return pretty_money(value)\
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", record.note) else "—"
|
||||
if PermissionBackend.check_perm(get_current_request(), "note.view_note", record.note) else "—"
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
@ -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',
|
||||
}
|
||||
|
||||
|
||||
@ -93,7 +95,7 @@ class MembershipTable(tables.Table):
|
||||
def render_user(self, value):
|
||||
# If the user has the right, link the displayed user with the page of its detail.
|
||||
s = value.username
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value):
|
||||
if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
|
||||
s = format_html("<a href={url}>{name}</a>",
|
||||
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
|
||||
|
||||
@ -102,7 +104,7 @@ class MembershipTable(tables.Table):
|
||||
def render_club(self, value):
|
||||
# If the user has the right, link the displayed club with the page of its detail.
|
||||
s = value.name
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value):
|
||||
if PermissionBackend.check_perm(get_current_request(), "member.view_club", value):
|
||||
s = format_html("<a href={url}>{name}</a>",
|
||||
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
|
||||
|
||||
@ -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,
|
||||
@ -127,7 +129,7 @@ class MembershipTable(tables.Table):
|
||||
date_end=date.today(),
|
||||
fee=0,
|
||||
)
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(),
|
||||
if PermissionBackend.check_perm(get_current_request(),
|
||||
"member.add_membership", empty_membership): # If the user has right
|
||||
renew_url = reverse_lazy('member:club_renew_membership',
|
||||
kwargs={"pk": record.pk})
|
||||
@ -142,7 +144,7 @@ class MembershipTable(tables.Table):
|
||||
# If the user has the right to manage the roles, display the link to manage them
|
||||
roles = record.roles.all()
|
||||
s = ", ".join(str(role) for role in roles)
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record):
|
||||
if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record):
|
||||
s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
|
||||
+ "'>" + s + "</a>")
|
||||
return s
|
||||
@ -165,7 +167,7 @@ class ClubManagerTable(tables.Table):
|
||||
def render_user(self, value):
|
||||
# If the user has the right, link the displayed user with the page of its detail.
|
||||
s = value.username
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value):
|
||||
if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
|
||||
s = format_html("<a href={url}>{name}</a>",
|
||||
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
|
||||
|
||||
|
@ -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 %}
|
||||
|
||||
{% 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>
|
||||
|
||||
|
@ -5,17 +5,26 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-info">
|
||||
<div class="row mt-4">
|
||||
<div class="col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3>{% trans "Token authentication" %}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<h4>À quoi sert un jeton d'authentification ?</h4>
|
||||
|
||||
Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a>.<br />
|
||||
Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a> via votre propre compte
|
||||
depuis un client externe.<br />
|
||||
Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token <TOKEN></code>
|
||||
pour pouvoir vous identifier.<br /><br />
|
||||
|
||||
Une documentation de l'API arrivera ultérieurement.
|
||||
</div>
|
||||
La documentation de l'API est disponible ici :
|
||||
<a href="/doc/api/">{{ request.scheme }}://{{ request.get_host }}/doc/api/</a>.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<div class="alert alert-info">
|
||||
<strong>{%trans 'Token' %} :</strong>
|
||||
{% if 'show' in request.GET %}
|
||||
{{ token.key }} (<a href="?">cacher</a>)
|
||||
@ -24,13 +33,70 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endif %}
|
||||
<br />
|
||||
<strong>{%trans 'Created' %} :</strong> {{ token.created }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<strong>Attention :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
|
||||
</div>
|
||||
|
||||
<a href="?regenerate">
|
||||
<div class="alert alert-warning">
|
||||
<strong>{% trans "Warning" %} :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a href="?regenerate">
|
||||
<button class="btn btn-primary">{% trans 'Regenerate token' %}</button>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3>{% trans "OAuth2 authentication" %}</h3>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
La Note Kfet implémente également le protocole <a href="https://oauth.net/2/">OAuth2</a>, afin de
|
||||
permettre à des applications tierces d'interagir avec la Note en récoltant des informations
|
||||
(de connexion par exemple) voir en permettant des modifications à distance, par exemple lorsqu'il
|
||||
s'agit d'avoir un site marchand sur lequel faire des transactions via la Note Kfet.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
L'usage de ce protocole est recommandé pour tout usage non personnel, car permet de mieux cibler
|
||||
les droits dont on a besoin, en restreignant leur usage par jeton généré.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
La documentation vis-à-vis de l'usage de ce protocole est disponible ici :
|
||||
<a href="/doc/external_services/oauth2/">{{ request.scheme }}://{{ request.get_host }}/doc/external_services/oauth2/</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Liste des URL à communiquer à votre application :
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
{% trans "Authorization:" %}
|
||||
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Token:" %}
|
||||
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:token' %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Revoke Token:" %}
|
||||
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:revoke-token' %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Introspect Token:" %}
|
||||
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:introspect' %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a class="btn btn-primary" href="{% url 'oauth2_provider:list' %}">{% trans "Show my applications" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
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%}
|
@ -291,7 +291,7 @@ class TestMemberships(TestCase):
|
||||
|
||||
response = self.client.post(reverse("member:club_manage_roles", args=(self.membership.pk,)), data=dict(
|
||||
roles=[role.id for role in Role.objects.filter(
|
||||
Q(name="Membre de club") | Q(name="Trésorier·ère de club") | Q(name="Bureau de club")).all()],
|
||||
Q(name="Membre de club") | Q(name="Trésorièr⋅e de club") | Q(name="Bureau de club")).all()],
|
||||
))
|
||||
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
|
||||
self.membership.refresh_from_db()
|
||||
|
@ -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,10 +19,10 @@ 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_kfet.middlewares import _set_current_user_and_ip
|
||||
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
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
@ -41,7 +42,8 @@ class CustomLoginView(LoginView):
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
logout(self.request)
|
||||
_set_current_user_and_ip(form.get_user(), self.request.session, None)
|
||||
self.request.user = form.get_user()
|
||||
_set_current_request(self.request)
|
||||
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
|
||||
return super().form_valid(form)
|
||||
|
||||
@ -70,7 +72,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
form.fields['email'].required = True
|
||||
form.fields['email'].help_text = _("This address must be valid.")
|
||||
|
||||
if PermissionBackend.check_perm(self.request.user, "member.change_profile", context['user_object'].profile):
|
||||
if PermissionBackend.check_perm(self.request, "member.change_profile", context['user_object'].profile):
|
||||
context['profile_form'] = self.profile_form(instance=context['user_object'].profile,
|
||||
data=self.request.POST if self.request.POST else None)
|
||||
if not self.object.profile.report_frequency:
|
||||
@ -153,13 +155,13 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
history_list = \
|
||||
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\
|
||||
.order_by("-created_at")\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))
|
||||
history_table = HistoryTable(history_list, prefix='transaction-')
|
||||
history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1))
|
||||
context['history_list'] = history_table
|
||||
|
||||
club_list = Membership.objects.filter(user=user, date_end__gte=date.today() - timedelta(days=15))\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))\
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\
|
||||
.order_by("club__name", "-date_start")
|
||||
# Display only the most recent membership
|
||||
club_list = club_list.distinct("club__name")\
|
||||
@ -173,24 +175,23 @@ 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.user, "note.change_noteuser_is_active",
|
||||
modified_note)
|
||||
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
|
||||
old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk)
|
||||
modified_note.inactivity_reason = 'forced'
|
||||
modified_note._force_save = True
|
||||
modified_note.save()
|
||||
context["can_force_lock"] = user.note.is_active and PermissionBackend\
|
||||
.check_perm(self.request.user, "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.user, "note.change_note_is_active", modified_note)
|
||||
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
|
||||
|
||||
return context
|
||||
|
||||
@ -237,12 +238,45 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
pre_registered_users = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view"))\
|
||||
pre_registered_users = User.objects.filter(PermissionBackend.filter_queryset(self.request, User, "view"))\
|
||||
.filter(profile__registration_valid=False)
|
||||
context["can_manage_registrations"] = pre_registered_users.exists()
|
||||
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,8 +290,9 @@ 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.user, Alias, "view")).distinct().all())
|
||||
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
|
||||
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="",
|
||||
normalized_name="",
|
||||
@ -382,7 +417,7 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["can_add_club"] = PermissionBackend.check_perm(self.request.user, "member.add_club", Club(
|
||||
context["can_add_club"] = PermissionBackend.check_perm(self.request, "member.add_club", Club(
|
||||
name="",
|
||||
email="club@example.com",
|
||||
))
|
||||
@ -403,9 +438,12 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
club = context["club"]
|
||||
if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", 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())\
|
||||
@ -413,7 +451,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
context["managers"] = ClubManagerTable(data=managers, prefix="managers-")
|
||||
# transaction history
|
||||
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))\
|
||||
.order_by('-created_at')
|
||||
history_table = HistoryTable(club_transactions, prefix="history-")
|
||||
history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
|
||||
@ -422,7 +460,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
club_member = Membership.objects.filter(
|
||||
club=club,
|
||||
date_end__gte=date.today() - timedelta(days=15),
|
||||
).filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))\
|
||||
).filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\
|
||||
.order_by("user__username", "-date_start")
|
||||
# Display only the most recent membership
|
||||
club_member = club_member.distinct("user__username")\
|
||||
@ -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
|
||||
|
||||
|
||||
@ -459,8 +520,8 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
context = super().get_context_data(**kwargs)
|
||||
note = context['object'].note
|
||||
context["aliases"] = AliasTable(note.alias.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all())
|
||||
context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias(
|
||||
PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
|
||||
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
|
||||
note=context["object"].note,
|
||||
name="",
|
||||
normalized_name="",
|
||||
@ -535,7 +596,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
form = context['form']
|
||||
|
||||
if "club_pk" in self.kwargs: # We create a new membership.
|
||||
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))\
|
||||
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view"))\
|
||||
.get(pk=self.kwargs["club_pk"], weiclub=None)
|
||||
form.fields['credit_amount'].initial = club.membership_fee_paid
|
||||
# Ensure that the user is member of the parent club and all its the family tree.
|
||||
@ -655,8 +716,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
if club.name != "Kfet" and club.parent_club and not Membership.objects.filter(
|
||||
user=form.instance.user,
|
||||
club=club.parent_club,
|
||||
date_start__lte=timezone.now(),
|
||||
date_end__gte=club.parent_club.membership_end,
|
||||
date_start__gte=club.parent_club.membership_start,
|
||||
).exists():
|
||||
form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name)
|
||||
error = True
|
||||
@ -684,7 +744,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
# Get the club that is concerned by the membership
|
||||
if "club_pk" in self.kwargs: # get from url of new membership
|
||||
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) \
|
||||
club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view")) \
|
||||
.get(pk=self.kwargs["club_pk"])
|
||||
user = form.instance.user
|
||||
old_membership = None
|
||||
@ -760,8 +820,8 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
ret = super().form_valid(form)
|
||||
|
||||
member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \
|
||||
if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \
|
||||
member_role = Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all() \
|
||||
if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all() \
|
||||
if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all()
|
||||
# Set the same roles as before
|
||||
if old_membership:
|
||||
@ -797,7 +857,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
membership.refresh_from_db()
|
||||
if old_membership.exists():
|
||||
membership.roles.set(old_membership.get().roles.all())
|
||||
membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
|
||||
membership.roles.set(Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
|
||||
membership.save()
|
||||
|
||||
return ret
|
||||
@ -868,7 +928,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
club = Club.objects.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Club, "view")
|
||||
PermissionBackend.filter_queryset(self.request, Club, "view")
|
||||
).get(pk=self.kwargs["pk"])
|
||||
context["club"] = club
|
||||
|
||||
|
@ -8,11 +8,11 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_polymorphic.serializers import PolymorphicSerializer
|
||||
from member.api.serializers import MembershipSerializer
|
||||
from member.models import Membership
|
||||
from note_kfet.middlewares import get_current_authenticated_user
|
||||
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.
|
||||
@ -126,7 +142,7 @@ class ConsumerSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
# If the user has no right to see the note, then we only display the note identifier
|
||||
return NotePolymorphicSerializer().to_representation(obj.note)\
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note)\
|
||||
if PermissionBackend.check_perm(get_current_request(), "note.view_note", obj.note)\
|
||||
else dict(
|
||||
id=obj.note.id,
|
||||
name=str(obj.note),
|
||||
@ -142,7 +158,7 @@ class ConsumerSerializer(serializers.ModelSerializer):
|
||||
def get_membership(self, obj):
|
||||
if isinstance(obj.note, NoteUser):
|
||||
memberships = Membership.objects.filter(
|
||||
PermissionBackend.filter_queryset(get_current_authenticated_user(), Membership, "view")).filter(
|
||||
PermissionBackend.filter_queryset(get_current_request(), Membership, "view")).filter(
|
||||
user=obj.note.user,
|
||||
club=2, # Kfet
|
||||
).order_by("-date_start")
|
||||
|
@ -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
|
||||
@ -10,12 +11,12 @@ from rest_framework import viewsets
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
|
||||
from note_kfet.middlewares import get_current_session
|
||||
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
|
||||
|
||||
|
||||
@ -40,12 +41,11 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
|
||||
Parse query and apply filters.
|
||||
:return: The filtered set of requested notes
|
||||
"""
|
||||
user = self.request.user
|
||||
get_current_session().setdefault("permission_mask", 42)
|
||||
queryset = self.queryset.filter(PermissionBackend.filter_queryset(user, Note, "view")
|
||||
| PermissionBackend.filter_queryset(user, NoteUser, "view")
|
||||
| PermissionBackend.filter_queryset(user, NoteClub, "view")
|
||||
| PermissionBackend.filter_queryset(user, NoteSpecial, "view")).distinct()
|
||||
queryset = self.queryset.filter(PermissionBackend.filter_queryset(self.request, Note, "view")
|
||||
| PermissionBackend.filter_queryset(self.request, NoteUser, "view")
|
||||
| PermissionBackend.filter_queryset(self.request, NoteClub, "view")
|
||||
| PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\
|
||||
.distinct()
|
||||
|
||||
alias = self.request.query_params.get("alias", ".*")
|
||||
queryset = queryset.filter(
|
||||
@ -57,17 +57,48 @@ 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
|
||||
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
|
||||
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
|
||||
filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
||||
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
|
||||
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
||||
ordering_fields = ['name', 'normalized_name', ]
|
||||
|
||||
def get_serializer_class(self):
|
||||
@ -118,7 +149,8 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
|
||||
serializer_class = ConsumerSerializer
|
||||
filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
|
||||
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
|
||||
filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
||||
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
|
||||
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
||||
ordering_fields = ['name', 'normalized_name', ]
|
||||
|
||||
def get_queryset(self):
|
||||
@ -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)
|
||||
|
||||
@ -205,7 +245,5 @@ class TransactionViewSet(ReadProtectedModelViewSet):
|
||||
ordering_fields = ['created_at', 'amount', ]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
get_current_session().setdefault("permission_mask", 42)
|
||||
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\
|
||||
return self.model.objects.filter(PermissionBackend.filter_queryset(self.request, self.model, "view"))\
|
||||
.order_by("created_at", "id")
|
||||
|
@ -26,7 +26,7 @@ class TransactionTemplateForm(forms.ModelForm):
|
||||
# We don't evaluate the content type at launch because the DB might be not initialized
|
||||
'api_url_suffix':
|
||||
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteClub).pk),
|
||||
'placeholder': 'Note ...',
|
||||
'placeholder': 'Note…',
|
||||
},
|
||||
),
|
||||
'amount': AmountInput(),
|
||||
@ -43,7 +43,7 @@ class SearchTransactionForm(forms.Form):
|
||||
resetable=True,
|
||||
attrs={
|
||||
'api_url': '/api/note/alias/',
|
||||
'placeholder': 'Note ...',
|
||||
'placeholder': 'Note…',
|
||||
},
|
||||
),
|
||||
)
|
||||
@ -57,7 +57,7 @@ class SearchTransactionForm(forms.Form):
|
||||
resetable=True,
|
||||
attrs={
|
||||
'api_url': '/api/note/alias/',
|
||||
'placeholder': 'Note ...',
|
||||
'placeholder': 'Note…',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
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': 'friendship',
|
||||
'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 = _("friendship")
|
||||
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.
|
||||
|
@ -20,7 +20,7 @@ class TemplateCategory(models.Model):
|
||||
"""
|
||||
Defined a recurrent transaction category
|
||||
|
||||
Example: food, softs, ...
|
||||
Example: food, softs,…
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_("name"),
|
||||
@ -40,7 +40,7 @@ class TransactionTemplate(models.Model):
|
||||
"""
|
||||
Defined a recurrent transaction
|
||||
|
||||
associated to selling something (a burger, a beer, ...)
|
||||
associated to selling something (a burger, a beer,…)
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
|
@ -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 }
|
||||
|
||||
@ -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_authenticated_user
|
||||
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
|
||||
|
||||
@ -88,16 +88,16 @@ class HistoryTable(tables.Table):
|
||||
"class": lambda record:
|
||||
str(record.valid).lower()
|
||||
+ (' validate' if record.source.is_active and record.destination.is_active and PermissionBackend
|
||||
.check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record)
|
||||
.check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record)
|
||||
else ''),
|
||||
"data-toggle": "tooltip",
|
||||
"title": lambda record: (_("Click to invalidate") if record.valid else _("Click to validate"))
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(),
|
||||
if PermissionBackend.check_perm(get_current_request(),
|
||||
"note.change_transaction_invalidity_reason", record)
|
||||
and record.source.is_active and record.destination.is_active else None,
|
||||
"onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower()
|
||||
+ ', "' + str(record.__class__.__name__) + '")'
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(),
|
||||
if PermissionBackend.check_perm(get_current_request(),
|
||||
"note.change_transaction_invalidity_reason", record)
|
||||
and record.source.is_active and record.destination.is_active else None,
|
||||
"onmouseover": lambda record: '$("#invalidity_reason_'
|
||||
@ -126,7 +126,7 @@ class HistoryTable(tables.Table):
|
||||
When the validation status is hovered, an input field is displayed to let the user specify an invalidity reason
|
||||
"""
|
||||
has_perm = PermissionBackend \
|
||||
.check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record)
|
||||
.check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record)
|
||||
|
||||
val = "✔" if value else "✖"
|
||||
|
||||
@ -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 = {
|
||||
@ -165,7 +190,7 @@ class AliasTable(tables.Table):
|
||||
extra_context={"delete_trans": _('delete')},
|
||||
attrs={'td': {'class': lambda record: 'col-sm-1' + (
|
||||
' d-none' if not PermissionBackend.check_perm(
|
||||
get_current_authenticated_user(), "note.delete_alias",
|
||||
get_current_request(), "note.delete_alias",
|
||||
record) else '')}}, verbose_name=_("Delete"), )
|
||||
|
||||
|
||||
@ -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)
|
||||
|
@ -40,7 +40,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{# User search with autocompletion #}
|
||||
<div class="card-footer">
|
||||
<input class="form-control mx-auto d-block"
|
||||
placeholder="{% trans "Name or alias..." %}" type="text" id="note" autofocus />
|
||||
placeholder="{% trans "Name or alias…" %}" type="text" id="note" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -22,7 +22,7 @@
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
|
||||
Par ailleurs, le BDE ne sert pas d'alcool aux adhérent⋅es dont le solde
|
||||
est inférieur à 0 € depuis plus de 24h.
|
||||
</p>
|
||||
|
||||
|
@ -9,7 +9,7 @@ Ce mail t'a été envoyé parce que le solde de ta Note Kfet
|
||||
|
||||
Ton solde actuel est de {{ note.balance|pretty_money }}.
|
||||
|
||||
Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
|
||||
Par ailleurs, le BDE ne sert pas d'alcool aux adhérent⋅es dont le solde
|
||||
est inférieur à 0 € depuis plus de 24h.
|
||||
|
||||
Si tu ne comprends pas ton solde, tu peux consulter ton historique
|
||||
|
@ -10,6 +10,7 @@ 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-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">
|
||||
@ -25,6 +26,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
{% 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 }}
|
||||
@ -62,7 +66,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias..." %}" />
|
||||
<input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias…" %}" />
|
||||
<div id="source_me_div">
|
||||
<hr>
|
||||
<a class="btn-block btn btn-secondary" href="#" id="source_me" data-turbolinks="false">
|
||||
@ -89,14 +93,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="form-control mx-auto" type="text" id="dest_note" placeholder="{% trans "Name or alias..." %}" />
|
||||
<input class="form-control mx-auto" type="text" id="dest_note" placeholder="{% trans "Name or alias…" %}" />
|
||||
<ul class="list-group list-group-flush" id="dest_alias_matched">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Information on transaction (amount, reason, name,...) #}
|
||||
{# Information on transaction (amount, reason, name,…) #}
|
||||
<div class="col-md" id="external_div">
|
||||
<div class="card bg-light mb-4">
|
||||
<div class="card-header">
|
||||
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<div class="row justify-content-center mb-4">
|
||||
<div class="col-md-10 text-center">
|
||||
{# Search field , see js #}
|
||||
<input class="form-control mx-auto w-25" type="text" id="search_field" placeholder="{% trans "Name of the button..." %}" value="{{ request.GET.search }}">
|
||||
<input class="form-control mx-auto w-25" type="text" id="search_field" placeholder="{% trans "Name of the button…" %}" value="{{ request.GET.search }}">
|
||||
<hr>
|
||||
<a class="btn btn-primary text-center my-1" href="{% url 'note:template_create' %}" data-turbolinks="false">{% trans "New button" %}</a>
|
||||
</div>
|
||||
@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<div class="col-md-12">
|
||||
<div class="card card-border shadow">
|
||||
<div class="card-header text-center">
|
||||
<h5> {% trans "buttons listing "%}</h5>
|
||||
<h5>{% trans "buttons listing"%}</h5>
|
||||
</div>
|
||||
<div class="card-body px-0 py-0" id="buttons_table">
|
||||
{% render_table table %}
|
||||
@ -31,14 +31,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
let searchbar_obj = $("#search_field");
|
||||
let timer_on = false;
|
||||
let timer;
|
||||
|
||||
function refreshMatchedWords() {
|
||||
$("tr").each(function() {
|
||||
let pattern = searchbar_obj.val();
|
||||
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>"));
|
||||
@ -47,13 +42,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
});
|
||||
}
|
||||
|
||||
refreshMatchedWords();
|
||||
|
||||
function reloadTable() {
|
||||
let pattern = searchbar_obj.val();
|
||||
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;
|
||||
|
||||
refreshMatchedWords();
|
||||
|
||||
searchbar_obj.keyup(function() {
|
||||
if (timer_on)
|
||||
clearTimeout(timer);
|
||||
@ -70,12 +70,35 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
headers: {"X-CSRFTOKEN": CSRF_TOKEN}
|
||||
})
|
||||
.done(function() {
|
||||
addMsg('{% trans "button successfully deleted "%}','success');
|
||||
addMsg('{% trans "button successfully deleted"%}','success');
|
||||
$("#buttons_table").load(location.pathname + "?search=" + $("#search_field").val().replace(" ", "%20") + " #buttons_table");
|
||||
})
|
||||
.fail(function() {
|
||||
addMsg('{% trans "Unable to delete button "%} #' + button_id, 'danger')
|
||||
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 %}
|
||||
|
@ -38,7 +38,7 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
|
||||
def get_queryset(self, **kwargs):
|
||||
# retrieves only Transaction that user has the right to see.
|
||||
return Transaction.objects.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
|
||||
PermissionBackend.filter_queryset(self.request, Transaction, "view")
|
||||
).order_by("-created_at").all()[:20]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -47,16 +47,16 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl
|
||||
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
|
||||
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
|
||||
context['special_types'] = NoteSpecial.objects\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, NoteSpecial, "view"))\
|
||||
.filter(PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\
|
||||
.order_by("special_type").all()
|
||||
|
||||
# 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(
|
||||
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all()
|
||||
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.user,
|
||||
if PermissionBackend.check_perm(self.request,
|
||||
"activity.add_entry",
|
||||
Entry(activity=a,
|
||||
note=self.request.user.note, ))]
|
||||
@ -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,7 +159,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
return self.handle_no_permission()
|
||||
|
||||
templates = TransactionTemplate.objects.filter(
|
||||
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
|
||||
PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
|
||||
)
|
||||
if not templates.exists():
|
||||
raise PermissionDenied(_("You can't see any button."))
|
||||
@ -170,7 +170,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
restrict to the transaction history the user can see.
|
||||
"""
|
||||
return Transaction.objects.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Transaction, "view")
|
||||
PermissionBackend.filter_queryset(self.request, Transaction, "view")
|
||||
).order_by("-created_at").all()[:20]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -180,13 +180,13 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
# for each category, find which transaction templates the user can see.
|
||||
for category in categories:
|
||||
category.templates_filtered = category.templates.filter(
|
||||
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
|
||||
PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
|
||||
).filter(display=True).order_by('name').all()
|
||||
|
||||
context['categories'] = [cat for cat in categories if cat.templates_filtered]
|
||||
# some transactiontemplate are put forward to find them easily
|
||||
context['highlighted'] = TransactionTemplate.objects.filter(highlighted=True).filter(
|
||||
PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view")
|
||||
PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view")
|
||||
).order_by('name').all()
|
||||
context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
|
||||
|
||||
@ -209,7 +209,7 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView
|
||||
data = form.cleaned_data if form.is_valid() else {}
|
||||
|
||||
transactions = Transaction.objects.annotate(total_amount=F("quantity") * F("amount")).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\
|
||||
PermissionBackend.filter_queryset(self.request, Transaction, "view"))\
|
||||
.filter(Q(source=self.object) | Q(destination=self.object)).order_by('-created_at')
|
||||
|
||||
if "source" in data and data["source"]:
|
||||
|
@ -4,12 +4,12 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.contrib.auth.models import User, AnonymousUser
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q, F
|
||||
from django.utils import timezone
|
||||
from note.models import Note, NoteUser, NoteClub, NoteSpecial
|
||||
from note_kfet.middlewares import get_current_session
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from member.models import Membership, Club
|
||||
|
||||
from .decorators import memoize
|
||||
@ -26,14 +26,31 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
@staticmethod
|
||||
@memoize
|
||||
def get_raw_permissions(user, t):
|
||||
def get_raw_permissions(request, t):
|
||||
"""
|
||||
Query permissions of a certain type for a user, then memoize it.
|
||||
:param user: The owner of the permissions
|
||||
:param request: The current request
|
||||
:param t: The type of the permissions: view, change, add or delete
|
||||
:return: The queryset of the permissions of the user (memoized) grouped by clubs
|
||||
"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# OAuth2 Authentication
|
||||
user = request.auth.user
|
||||
|
||||
def permission_filter(membership_obj):
|
||||
query = Q(pk=-1)
|
||||
for scope in request.auth.scope.split(' '):
|
||||
permission_id, club_id = scope.split('_')
|
||||
if int(club_id) == membership_obj.club_id:
|
||||
query |= Q(pk=permission_id)
|
||||
return query
|
||||
else:
|
||||
user = request.user
|
||||
|
||||
def permission_filter(membership_obj):
|
||||
return Q(mask__rank__lte=request.session.get("permission_mask", 42))
|
||||
|
||||
if user.is_anonymous:
|
||||
# Unauthenticated users have no permissions
|
||||
return Permission.objects.none()
|
||||
|
||||
@ -43,7 +60,7 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
for membership in memberships:
|
||||
for role in membership.roles.all():
|
||||
for perm in role.permissions.filter(type=t, mask__rank__lte=get_current_session().get("permission_mask", -1)).all():
|
||||
for perm in role.permissions.filter(permission_filter(membership), type=t).all():
|
||||
if not perm.permanent:
|
||||
if membership.date_start > date.today() or membership.date_end < date.today():
|
||||
continue
|
||||
@ -52,16 +69,22 @@ class PermissionBackend(ModelBackend):
|
||||
return perms
|
||||
|
||||
@staticmethod
|
||||
def permissions(user, model, type):
|
||||
def permissions(request, model, type):
|
||||
"""
|
||||
List all permissions of the given user that applies to a given model and a give type
|
||||
:param user: The owner of the permissions
|
||||
:param request: The current request
|
||||
:param model: The model that the permissions shoud apply
|
||||
:param type: The type of the permissions: view, change, add or delete
|
||||
:return: A generator of the requested permissions
|
||||
"""
|
||||
|
||||
for permission in PermissionBackend.get_raw_permissions(user, type):
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# OAuth2 Authentication
|
||||
user = request.auth.user
|
||||
else:
|
||||
user = request.user
|
||||
|
||||
for permission in PermissionBackend.get_raw_permissions(request, type):
|
||||
if not isinstance(model.model_class()(), permission.model.model_class()) or not permission.membership:
|
||||
continue
|
||||
|
||||
@ -88,20 +111,26 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
@staticmethod
|
||||
@memoize
|
||||
def filter_queryset(user, model, t, field=None):
|
||||
def filter_queryset(request, model, t, field=None):
|
||||
"""
|
||||
Filter a queryset by considering the permissions of a given user.
|
||||
:param user: The owner of the permissions that are fetched
|
||||
:param request: The current request
|
||||
:param model: The concerned model of the queryset
|
||||
:param t: The type of modification (view, add, change, delete)
|
||||
:param field: The field of the model to test, if concerned
|
||||
:return: A query that corresponds to the filter to give to a queryset
|
||||
"""
|
||||
if user is None or isinstance(user, AnonymousUser):
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# OAuth2 Authentication
|
||||
user = request.auth.user
|
||||
else:
|
||||
user = request.user
|
||||
|
||||
if user is None or user.is_anonymous:
|
||||
# Anonymous users can't do anything
|
||||
return Q(pk=-1)
|
||||
|
||||
if user.is_superuser and get_current_session().get("permission_mask", -1) >= 42:
|
||||
if user.is_superuser and request.session.get("permission_mask", -1) >= 42:
|
||||
# Superusers have all rights
|
||||
return Q()
|
||||
|
||||
@ -110,7 +139,7 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
# Never satisfied
|
||||
query = Q(pk=-1)
|
||||
perms = PermissionBackend.permissions(user, model, t)
|
||||
perms = PermissionBackend.permissions(request, model, t)
|
||||
for perm in perms:
|
||||
if perm.field and field != perm.field:
|
||||
continue
|
||||
@ -122,7 +151,7 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
@staticmethod
|
||||
@memoize
|
||||
def check_perm(user_obj, perm, obj=None):
|
||||
def check_perm(request, perm, obj=None):
|
||||
"""
|
||||
Check is the given user has the permission over a given object.
|
||||
The result is then memoized.
|
||||
@ -130,10 +159,19 @@ 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)
|
||||
"""
|
||||
if user_obj is None or isinstance(user_obj, AnonymousUser):
|
||||
# Requested by a shell
|
||||
if request is None:
|
||||
return False
|
||||
|
||||
sess = get_current_session()
|
||||
user_obj = request.user
|
||||
sess = request.session
|
||||
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# OAuth2 Authentication
|
||||
user_obj = request.auth.user
|
||||
|
||||
if user_obj is None or user_obj.is_anonymous:
|
||||
return False
|
||||
|
||||
if user_obj.is_superuser and sess.get("permission_mask", -1) >= 42:
|
||||
return True
|
||||
@ -147,16 +185,19 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
ct = ContentType.objects.get_for_model(obj)
|
||||
if any(permission.applies(obj, perm_type, perm_field)
|
||||
for permission in PermissionBackend.permissions(user_obj, ct, perm_type)):
|
||||
for permission in PermissionBackend.permissions(request, ct, perm_type)):
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_perm(self, user_obj, perm, obj=None):
|
||||
return PermissionBackend.check_perm(user_obj, perm, obj)
|
||||
# Warning: this does not check that user_obj has the permission,
|
||||
# but if the current request has the permission.
|
||||
# This function is implemented for backward compatibility, and should not be used.
|
||||
return PermissionBackend.check_perm(get_current_request(), perm, obj)
|
||||
|
||||
def has_module_perms(self, user_obj, app_label):
|
||||
return False
|
||||
|
||||
def get_all_permissions(self, user_obj, obj=None):
|
||||
ct = ContentType.objects.get_for_model(obj)
|
||||
return list(self.permissions(user_obj, ct, "view"))
|
||||
return list(self.permissions(get_current_request(), ct, "view"))
|
||||
|
@ -5,7 +5,7 @@ from functools import lru_cache
|
||||
from time import time
|
||||
|
||||
from django.contrib.sessions.models import Session
|
||||
from note_kfet.middlewares import get_current_session
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
|
||||
def memoize(f):
|
||||
@ -48,11 +48,11 @@ def memoize(f):
|
||||
last_collect = time()
|
||||
|
||||
# If there is no session, then we don't memoize anything.
|
||||
sess = get_current_session()
|
||||
if sess is None or sess.session_key is None:
|
||||
request = get_current_request()
|
||||
if request is None or request.session is None or request.session.session_key is None:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
sess_key = sess.session_key
|
||||
sess_key = request.session.session_key
|
||||
if sess_key not in sess_funs:
|
||||
# lru_cache makes the job of memoization
|
||||
# We store only the 512 latest data per session. It has to be enough.
|
||||
|
@ -36,7 +36,7 @@
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Voir son compte utilisateur"
|
||||
"description": "Voir son compte utilisateur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -68,7 +68,7 @@
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Voir sa propre note d'utilisateur"
|
||||
"description": "Voir sa propre note d'utilisateur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -111,12 +111,12 @@
|
||||
"note",
|
||||
"alias"
|
||||
],
|
||||
"query": "[\"AND\", [\"OR\", {\"note__noteuser__user__memberships__club__name\": \"Kfet\", \"note__noteuser__user__memberships__date_start__lte\": [\"today\"], \"note__noteuser__user__memberships__date_end__gte\": [\"today\"]}, {\"note__noteclub__isnull\": false}], {\"note__is_active\": true}]",
|
||||
"query": "[\"AND\", [\"OR\", {\"note__noteuser__user__memberships__club__name\": \"BDE\", \"note__noteuser__user__memberships__date_start__lte\": [\"today\"], \"note__noteuser__user__memberships__date_end__gte\": [\"today\"]}, {\"note__noteclub__isnull\": false}], {\"note__is_active\": true}]",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir les aliases des notes des clubs et des adhérents du club Kfet"
|
||||
"description": "Voir les alias des notes des clubs et des adhérent⋅es du club BDE"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -627,7 +627,7 @@
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Voir les personnes qu'on a invitées"
|
||||
}
|
||||
},
|
||||
@ -772,7 +772,7 @@
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir les adhérents du club"
|
||||
"description": "Voir les adhérent⋅es du club"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -788,7 +788,7 @@
|
||||
"mask": 2,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Ajouter un membre à un club"
|
||||
"description": "Ajouter un⋅e membre à un club"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -852,7 +852,7 @@
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Modifier n'importe quel utilisateur"
|
||||
"description": "Modifier n'importe quel⋅le utilisateur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -868,7 +868,7 @@
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Ajouter un utilisateur"
|
||||
"description": "Ajouter un⋅e utilisateur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -977,7 +977,7 @@
|
||||
],
|
||||
"query": "[\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}]",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"mask": 2,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir les transactions d'un club"
|
||||
@ -1235,7 +1235,7 @@
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Voir le dernier WEI"
|
||||
}
|
||||
},
|
||||
@ -1267,7 +1267,7 @@
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "M'inscrire au dernier WEI"
|
||||
}
|
||||
},
|
||||
@ -1284,7 +1284,7 @@
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Inscrire un 1A au WEI"
|
||||
"description": "Inscrire un⋅e 1A au WEI"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -1331,7 +1331,7 @@
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Voir ma propre inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -1379,7 +1379,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "soge_credit",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Indiquer si mon inscription WEI est payée par la Société générale tant qu'elle n'est pas validée"
|
||||
}
|
||||
},
|
||||
@ -1427,7 +1427,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "birth_date",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier la date de naissance de ma propre inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -1459,7 +1459,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "gender",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier le genre de ma propre inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -1491,7 +1491,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "health_issues",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier mes problèmes de santé de mon inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -1523,7 +1523,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "emergency_contact_name",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier le nom du contact en cas d'urgence de mon inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -1555,7 +1555,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "emergency_contact_phone",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier le téléphone du contact en cas d'urgence de mon inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -1572,7 +1572,7 @@
|
||||
"mask": 1,
|
||||
"field": "information_json",
|
||||
"permanent": false,
|
||||
"description": "Modifier les informations (sondage 1A, ...) d'une inscription WEI"
|
||||
"description": "Modifier les informations (sondage 1A,…) d'une inscription WEI"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -1699,7 +1699,7 @@
|
||||
"type": "add",
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Créer une adhésion WEI pour le dernier WEI"
|
||||
}
|
||||
},
|
||||
@ -1956,7 +1956,7 @@
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Voir mes activitées passées, même après la fin de l'adhésion BDE"
|
||||
"description": "Voir mes activités passées, même après la fin de l'adhésion BDE"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2003,7 +2003,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "clothing_cut",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier ma coupe de vêtements de mon inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -2035,7 +2035,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "clothing_size",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier la taille de vêtements de mon inscription WEI"
|
||||
}
|
||||
},
|
||||
@ -2100,7 +2100,7 @@
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir n'importe quel utilisateur"
|
||||
"description": "Voir n'importe quel⋅le utilisateur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2228,7 +2228,7 @@
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Créer une note d'utilisateur"
|
||||
"description": "Créer une note d'utilisateur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2243,7 +2243,7 @@
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "information_json",
|
||||
"permanent": false,
|
||||
"permanent": true,
|
||||
"description": "Modifier mes préférences en terme de bus et d'équipe si mon inscription n'est pas validée et que je suis en 2A+"
|
||||
}
|
||||
},
|
||||
@ -2276,7 +2276,7 @@
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir tous les adhérents du club"
|
||||
"description": "Voir toustes les adhérent⋅es du club"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2292,7 +2292,7 @@
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Ajouter un membre à n'importe quel club"
|
||||
"description": "Ajouter un⋅e membre à n'importe quel club"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2372,7 +2372,7 @@
|
||||
"mask": 1,
|
||||
"field": "name",
|
||||
"permanent": false,
|
||||
"description": "Modifier le nom d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier le nom d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2388,7 +2388,7 @@
|
||||
"mask": 1,
|
||||
"field": "description",
|
||||
"permanent": false,
|
||||
"description": "Modifier la description d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier la description d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2404,7 +2404,7 @@
|
||||
"mask": 1,
|
||||
"field": "location",
|
||||
"permanent": false,
|
||||
"description": "Modifier le lieu d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier le lieu d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2420,7 +2420,7 @@
|
||||
"mask": 1,
|
||||
"field": "activity_type",
|
||||
"permanent": false,
|
||||
"description": "Modifier le type d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier le type d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2436,7 +2436,7 @@
|
||||
"mask": 1,
|
||||
"field": "organizer",
|
||||
"permanent": false,
|
||||
"description": "Modifier l'organisateur d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier l'organisateur d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2452,7 +2452,7 @@
|
||||
"mask": 1,
|
||||
"field": "attendees_club",
|
||||
"permanent": false,
|
||||
"description": "Modifier le club attendu d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier le club attendu d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2468,7 +2468,7 @@
|
||||
"mask": 1,
|
||||
"field": "date_start",
|
||||
"permanent": false,
|
||||
"description": "Modifier la date de début d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier la date de début d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2484,7 +2484,7 @@
|
||||
"mask": 1,
|
||||
"field": "date_end",
|
||||
"permanent": false,
|
||||
"description": "Modifier la date de fin d'une activité non validée dont on est l'auteur"
|
||||
"description": "Modifier la date de fin d'une activité non validée dont on est l'auteur⋅rice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2511,7 +2511,7 @@
|
||||
"note",
|
||||
"noteuser"
|
||||
],
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]",
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "is_active",
|
||||
@ -2527,7 +2527,7 @@
|
||||
"note",
|
||||
"noteuser"
|
||||
],
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]",
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "inactivity_reason",
|
||||
@ -2756,7 +2756,7 @@
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Modifier n'importe quel utilisateur non encore inscrit"
|
||||
"description": "Modifier n'importe quel⋅le utilisateur⋅rice non encore inscrit⋅e"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2788,7 +2788,7 @@
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir tous les alias, y compris ceux des non adhérents"
|
||||
"description": "Voir tous les alias, y compris ceux des non adhérent⋅es"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2820,7 +2820,7 @@
|
||||
"mask": 2,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir n'importe quel utilisateur non encore inscrit"
|
||||
"description": "Voir n'importe quel⋅le utilisateur⋅rice non encore inscrit⋅e"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2847,12 +2847,12 @@
|
||||
"auth",
|
||||
"user"
|
||||
],
|
||||
"query": "{\"memberships__club__name\": \"BDE\", \"memberships__roles__name\": \"Adhérent BDE\", \"memberships__date_start__lte\": [\"today\"], \"memberships__date_end__gte\": [\"today\"]}",
|
||||
"query": "{\"memberships__club__name\": \"BDE\", \"memberships__roles__name\": \"Adhérent⋅e BDE\", \"memberships__date_start__lte\": [\"today\"], \"memberships__date_end__gte\": [\"today\"]}",
|
||||
"type": "view",
|
||||
"mask": 2,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir n'importe quel utilisateur qui est adhérent BDE"
|
||||
"description": "Voir n'importe quel⋅le utilisateur⋅rice qui est adhérent⋅e BDE"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2871,18 +2871,227 @@
|
||||
"description": "Changer l'image de n'importe quelle note"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 184,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"noteclub"
|
||||
],
|
||||
"query": "[\"AND\", {\"club\": [\"club\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 3,
|
||||
"field": "is_active",
|
||||
"permanent": true,
|
||||
"description": "(Dé)bloquer la note de son club manuellement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 185,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"noteclub"
|
||||
],
|
||||
"query": "[\"AND\", {\"club\": [\"club\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 3,
|
||||
"field": "inactivity_reason",
|
||||
"permanent": true,
|
||||
"description": "(Dé)bloquer la note de son club et indiquer que cela a été fait manuellement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 186,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Voir ses applications OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 187,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Créer une application OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 188,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Modifier une application OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 189,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "delete",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Supprimer une application OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 190,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting\": [\"user\", \"note\"]}",
|
||||
"type": "delete",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Supprimer une amitié à sa note"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 191,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting\": [\"user\", \"note\"]}",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Ajouter une amitié à sa note"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 192,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting__is_active\": true}",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Ajouter une amitié à une note non bloquée"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 193,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting__is_active\": true}",
|
||||
"type": "delete",
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Supprimer une amitié à une note non bloquée"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 194,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{}",
|
||||
"type": "view",
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir toutes les amitiés, y compris celles des non adhérent⋅es"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 195,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting__noteuser__user\": [\"user\"]}",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Voir ses propres amitiés, pour toujours"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 196,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"transaction"
|
||||
],
|
||||
"query": "[\"AND\", {\"source__trusting__trusted\": [\"user\", \"note\"]}, [\"OR\", {\"source__balance__gte\": {\"F\": [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]]}}, {\"valid\": false}]]",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Transférer de l'argent depuis une note amie en restant positif"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.role",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"for_club": 1,
|
||||
"name": "Adh\u00e9rent BDE",
|
||||
"name": "Adh\u00e9rent\u22c5e BDE",
|
||||
"permissions": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
@ -2890,13 +3099,25 @@
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
22,
|
||||
48,
|
||||
52,
|
||||
126,
|
||||
161,
|
||||
162,
|
||||
165
|
||||
165,
|
||||
186,
|
||||
187,
|
||||
188,
|
||||
189,
|
||||
190,
|
||||
191,
|
||||
195,
|
||||
196
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -2905,13 +3126,8 @@
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"for_club": 2,
|
||||
"name": "Adh\u00e9rent Kfet",
|
||||
"name": "Adh\u00e9rent\u22c5e Kfet",
|
||||
"permissions": [
|
||||
6,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
22,
|
||||
34,
|
||||
36,
|
||||
@ -2942,7 +3158,9 @@
|
||||
158,
|
||||
159,
|
||||
160,
|
||||
179
|
||||
179,
|
||||
189,
|
||||
190
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -2977,7 +3195,7 @@
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"for_club": null,
|
||||
"name": "Pr\u00e9sident\u00b7e de club",
|
||||
"name": "Pr\u00e9sident\u22c5e de club",
|
||||
"permissions": [
|
||||
50,
|
||||
62,
|
||||
@ -2991,7 +3209,7 @@
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"for_club": null,
|
||||
"name": "Tr\u00e9sorier\u00b7\u00e8re de club",
|
||||
"name": "Tr\u00e9sorièr\u22c5e de club",
|
||||
"permissions": [
|
||||
59,
|
||||
19,
|
||||
@ -3010,7 +3228,9 @@
|
||||
166,
|
||||
167,
|
||||
168,
|
||||
182
|
||||
182,
|
||||
184,
|
||||
185
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -3019,7 +3239,7 @@
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"for_club": 1,
|
||||
"name": "Pr\u00e9sident\u00b7e BDE",
|
||||
"name": "Pr\u00e9sident\u22c5e BDE",
|
||||
"permissions": [
|
||||
24,
|
||||
25,
|
||||
@ -3035,7 +3255,7 @@
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"for_club": 1,
|
||||
"name": "Tr\u00e9sorier\u00b7\u00e8re BDE",
|
||||
"name": "Tr\u00e9sorièr\u22c5e BDE",
|
||||
"permissions": [
|
||||
23,
|
||||
24,
|
||||
@ -3048,6 +3268,7 @@
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
43,
|
||||
51,
|
||||
53,
|
||||
54,
|
||||
@ -3089,7 +3310,10 @@
|
||||
176,
|
||||
177,
|
||||
178,
|
||||
183
|
||||
188,
|
||||
183,
|
||||
186,
|
||||
187
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -3277,7 +3501,20 @@
|
||||
180,
|
||||
181,
|
||||
182,
|
||||
183
|
||||
183,
|
||||
184,
|
||||
185,
|
||||
186,
|
||||
187,
|
||||
188,
|
||||
189,
|
||||
190,
|
||||
191,
|
||||
192,
|
||||
193,
|
||||
194,
|
||||
195,
|
||||
196
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -3304,6 +3541,7 @@
|
||||
30,
|
||||
31,
|
||||
70,
|
||||
72,
|
||||
143,
|
||||
166,
|
||||
167,
|
||||
@ -3336,7 +3574,8 @@
|
||||
45,
|
||||
46,
|
||||
148,
|
||||
149
|
||||
149,
|
||||
182
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -3379,7 +3618,7 @@
|
||||
"pk": 13,
|
||||
"fields": {
|
||||
"for_club": null,
|
||||
"name": "Chef de bus",
|
||||
"name": "Chef\u22c5fe de bus",
|
||||
"permissions": [
|
||||
22,
|
||||
84,
|
||||
@ -3397,7 +3636,7 @@
|
||||
"pk": 14,
|
||||
"fields": {
|
||||
"for_club": null,
|
||||
"name": "Chef d'\u00e9quipe",
|
||||
"name": "Chef\u22c5fe d'\u00e9quipe",
|
||||
"permissions": [
|
||||
22,
|
||||
84,
|
||||
@ -3446,7 +3685,7 @@
|
||||
"pk": 18,
|
||||
"fields": {
|
||||
"for_club": null,
|
||||
"name": "Adhérent WEI",
|
||||
"name": "Adhérent\u22c5e WEI",
|
||||
"permissions": [
|
||||
77,
|
||||
87,
|
||||
@ -3495,7 +3734,7 @@
|
||||
"model": "permission.role",
|
||||
"pk": 20,
|
||||
"fields": {
|
||||
"for_club": 2,
|
||||
"for_club": 1,
|
||||
"name": "PC Kfet",
|
||||
"permissions": [
|
||||
6,
|
||||
@ -3511,6 +3750,8 @@
|
||||
56,
|
||||
57,
|
||||
58,
|
||||
70,
|
||||
72,
|
||||
135,
|
||||
137,
|
||||
143,
|
||||
|
@ -59,7 +59,7 @@ class InstancedPermission:
|
||||
|
||||
# Force insertion, no data verification, no trigger
|
||||
obj._force_save = True
|
||||
# We don't want to trigger any signal (log, ...)
|
||||
# We don't want to trigger any signal (log,…)
|
||||
obj._no_signal = True
|
||||
Model.save(obj, force_insert=True)
|
||||
ret = self.model.model_class().objects.filter(self.query & Q(pk=0)).exists()
|
||||
@ -227,7 +227,7 @@ class Permission(models.Model):
|
||||
def compute_param(value, **kwargs):
|
||||
"""
|
||||
A parameter is given by a list. The first argument is the name of the parameter.
|
||||
The parameters are the user, the club, and some classes (Note, ...)
|
||||
The parameters are the user, the club, and some classes (Note,…)
|
||||
If there are more arguments in the list, then attributes are queried.
|
||||
For example, ["user", "note", "balance"] will return the balance of the note of the user.
|
||||
If an argument is a list, then this is interpreted with a function call:
|
||||
|
@ -45,7 +45,7 @@ class StrongDjangoObjectPermissions(DjangoObjectPermissions):
|
||||
|
||||
perms = self.get_required_object_permissions(request.method, model_cls)
|
||||
# if not user.has_perms(perms, obj):
|
||||
if not all(PermissionBackend.check_perm(user, perm, obj) for perm in perms):
|
||||
if not all(PermissionBackend.check_perm(request, perm, obj) for perm in perms):
|
||||
# If the user does not have permissions we need to determine if
|
||||
# they have read permissions to see 403, or not, and simply see
|
||||
# a 404 response.
|
||||
|
57
apps/permission/scopes.py
Normal file
57
apps/permission/scopes.py
Normal file
@ -0,0 +1,57 @@
|
||||
# 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
|
||||
|
||||
from .backends import PermissionBackend
|
||||
from .models import Permission
|
||||
|
||||
|
||||
class PermissionScopes(BaseScopes):
|
||||
"""
|
||||
An OAuth2 scope is defined by a permission object and a club.
|
||||
A token will have a subset of permissions from the owner of the application,
|
||||
and can be useful to make queries through the API with limited privileges.
|
||||
"""
|
||||
|
||||
def get_all_scopes(self):
|
||||
return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
||||
|
||||
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
if not application:
|
||||
return []
|
||||
return [f"{p.id}_{p.membership.club.id}"
|
||||
for t in Permission.PERMISSION_TYPES
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||
|
||||
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
if not application:
|
||||
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
|
@ -3,7 +3,7 @@
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.middlewares import get_current_authenticated_user
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
|
||||
@ -16,6 +16,9 @@ EXCLUDED = [
|
||||
'contenttypes.contenttype',
|
||||
'logs.changelog',
|
||||
'migrations.migration',
|
||||
'oauth2_provider.accesstoken',
|
||||
'oauth2_provider.grant',
|
||||
'oauth2_provider.refreshtoken',
|
||||
'sessions.session',
|
||||
]
|
||||
|
||||
@ -31,8 +34,8 @@ def pre_save_object(sender, instance, **kwargs):
|
||||
if hasattr(instance, "_force_save") or hasattr(instance, "_no_signal"):
|
||||
return
|
||||
|
||||
user = get_current_authenticated_user()
|
||||
if user is None:
|
||||
request = get_current_request()
|
||||
if request is None:
|
||||
# Action performed on shell is always granted
|
||||
return
|
||||
|
||||
@ -45,7 +48,7 @@ def pre_save_object(sender, instance, **kwargs):
|
||||
# We check if the user can change the model
|
||||
|
||||
# If the user has all right on a model, then OK
|
||||
if PermissionBackend.check_perm(user, app_label + ".change_" + model_name, instance):
|
||||
if PermissionBackend.check_perm(request, app_label + ".change_" + model_name, instance):
|
||||
return
|
||||
|
||||
# In the other case, we check if he/she has the right to change one field
|
||||
@ -58,7 +61,14 @@ 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 not PermissionBackend.check_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance):
|
||||
|
||||
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(
|
||||
_("You don't have the permission to change the field {field} on this instance of model"
|
||||
" {app_label}.{model_name}.")
|
||||
@ -66,7 +76,7 @@ def pre_save_object(sender, instance, **kwargs):
|
||||
)
|
||||
else:
|
||||
# We check if the user has right to add the object
|
||||
has_perm = PermissionBackend.check_perm(user, app_label + ".add_" + model_name, instance)
|
||||
has_perm = PermissionBackend.check_perm(request, app_label + ".add_" + model_name, instance)
|
||||
|
||||
if not has_perm:
|
||||
raise PermissionDenied(
|
||||
@ -87,8 +97,8 @@ def pre_delete_object(instance, **kwargs):
|
||||
# Don't check permissions on force-deleted objects
|
||||
return
|
||||
|
||||
user = get_current_authenticated_user()
|
||||
if user is None:
|
||||
request = get_current_request()
|
||||
if request is None:
|
||||
# Action performed on shell is always granted
|
||||
return
|
||||
|
||||
@ -97,7 +107,7 @@ def pre_delete_object(instance, **kwargs):
|
||||
model_name = model_name_full[1]
|
||||
|
||||
# We check if the user has rights to delete the object
|
||||
if not PermissionBackend.check_perm(user, app_label + ".delete_" + model_name, instance):
|
||||
if not PermissionBackend.check_perm(request, app_label + ".delete_" + model_name, instance):
|
||||
raise PermissionDenied(
|
||||
_("You don't have the permission to delete this instance of model {app_label}.{model_name}.")
|
||||
.format(app_label=app_label, model_name=model_name))
|
||||
|
@ -8,7 +8,7 @@ from django.urls import reverse_lazy
|
||||
from django.utils.html import format_html
|
||||
from django_tables2 import A
|
||||
from member.models import Membership
|
||||
from note_kfet.middlewares import get_current_authenticated_user
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ class RightsTable(tables.Table):
|
||||
def render_user(self, value):
|
||||
# If the user has the right, link the displayed user with the page of its detail.
|
||||
s = value.username
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value):
|
||||
if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
|
||||
s = format_html("<a href={url}>{name}</a>",
|
||||
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
|
||||
return s
|
||||
@ -28,7 +28,7 @@ class RightsTable(tables.Table):
|
||||
def render_club(self, value):
|
||||
# If the user has the right, link the displayed user with the page of its detail.
|
||||
s = value.name
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value):
|
||||
if PermissionBackend.check_perm(get_current_request(), "member.view_club", value):
|
||||
s = format_html("<a href={url}>{name}</a>",
|
||||
url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s)
|
||||
|
||||
@ -36,13 +36,13 @@ class RightsTable(tables.Table):
|
||||
|
||||
def render_roles(self, record):
|
||||
# If the user has the right to manage the roles, display the link to manage them
|
||||
roles = record.roles.filter((~(Q(name="Adhérent BDE")
|
||||
| Q(name="Adhérent Kfet")
|
||||
roles = record.roles.filter((~(Q(name="Adhérent⋅e BDE")
|
||||
| Q(name="Adhérent⋅e Kfet")
|
||||
| Q(name="Membre de club")
|
||||
| Q(name="Bureau de club"))
|
||||
& Q(weirole__isnull=True))).all()
|
||||
s = ", ".join(str(role) for role in roles)
|
||||
if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record):
|
||||
if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record):
|
||||
s = format_html("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
|
||||
+ "'>" + s + "</a>")
|
||||
return s
|
||||
|
73
apps/permission/templates/permission/scopes.html
Normal file
73
apps/permission/templates/permission/scopes.html
Normal file
@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h2>{% trans "Available scopes" %}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="accordion" id="accordionApps">
|
||||
{% for app, app_scopes in scopes.items %}
|
||||
<div class="card">
|
||||
<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|slugify }}" aria-expanded="false"
|
||||
aria-controls="app-{{ app.name|slugify }}">
|
||||
{{ app.name }}
|
||||
</a>
|
||||
</div>
|
||||
<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|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|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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>
|
||||
{% trans "No applications defined" %}.
|
||||
<a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}.
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
{% for app in scopes.keys %}
|
||||
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
|
||||
element.onchange = function (event) {
|
||||
let scope = ""
|
||||
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
|
||||
if (element.checked) {
|
||||
scope += element.value + " "
|
||||
}
|
||||
}
|
||||
|
||||
scope = scope.substr(0, scope.length - 1)
|
||||
|
||||
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>'
|
||||
}
|
||||
}
|
||||
{% endfor %}
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,12 +1,12 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.defaultfilters import stringfilter
|
||||
from django import template
|
||||
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
|
||||
from permission.backends import PermissionBackend
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
from ..backends import PermissionBackend
|
||||
|
||||
|
||||
@stringfilter
|
||||
@ -14,9 +14,10 @@ def not_empty_model_list(model_name):
|
||||
"""
|
||||
Return True if and only if the current user has right to see any object of the given model.
|
||||
"""
|
||||
user = get_current_authenticated_user()
|
||||
session = get_current_session()
|
||||
if user is None or isinstance(user, AnonymousUser):
|
||||
request = get_current_request()
|
||||
user = request.user
|
||||
session = request.session
|
||||
if user is None or not user.is_authenticated:
|
||||
return False
|
||||
elif user.is_superuser and session.get("permission_mask", -1) >= 42:
|
||||
return True
|
||||
@ -29,11 +30,12 @@ def model_list(model_name, t="view", fetch=True):
|
||||
"""
|
||||
Return the queryset of all visible instances of the given model.
|
||||
"""
|
||||
user = get_current_authenticated_user()
|
||||
request = get_current_request()
|
||||
user = request.user
|
||||
spl = model_name.split(".")
|
||||
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
|
||||
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, t))
|
||||
if user is None or isinstance(user, AnonymousUser):
|
||||
qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(request, ct, t))
|
||||
if user is None or not user.is_authenticated:
|
||||
return qs.none()
|
||||
if fetch:
|
||||
qs = qs.all()
|
||||
@ -49,7 +51,7 @@ def model_list_length(model_name, t="view"):
|
||||
|
||||
|
||||
def has_perm(perm, obj):
|
||||
return PermissionBackend.check_perm(get_current_authenticated_user(), perm, obj)
|
||||
return PermissionBackend.check_perm(get_current_request(), perm, obj)
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
94
apps/permission/tests/test_oauth2.py
Normal file
94
apps/permission/tests/test_oauth2.py
Normal file
@ -0,0 +1,94 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from member.models import Membership, Club
|
||||
from note.models import NoteUser
|
||||
from oauth2_provider.models import Application, AccessToken
|
||||
|
||||
from ..models import Role, Permission
|
||||
|
||||
|
||||
class OAuth2TestCase(TestCase):
|
||||
fixtures = ('initial', )
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(
|
||||
username="toto",
|
||||
)
|
||||
self.application = Application.objects.create(
|
||||
name="Test",
|
||||
client_type=Application.CLIENT_PUBLIC,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
def test_oauth2_access(self):
|
||||
"""
|
||||
Create a simple OAuth2 access token that only has the right to see data of the current user
|
||||
and check that this token has required access, and nothing more.
|
||||
"""
|
||||
|
||||
bde = Club.objects.get(name="BDE")
|
||||
view_user_perm = Permission.objects.get(pk=1) # View own user detail
|
||||
|
||||
# Create access token that has access to our own user detail
|
||||
token = AccessToken.objects.create(
|
||||
user=self.user,
|
||||
application=self.application,
|
||||
scope=f"{view_user_perm.pk}_{bde.pk}",
|
||||
token=get_random_string(64),
|
||||
expires=timezone.now() + timedelta(days=365),
|
||||
)
|
||||
|
||||
# No access without token
|
||||
resp = self.client.get(f'/api/user/{self.user.pk}/')
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
# Valid token but user has no membership, so the query is not returning the user object
|
||||
resp = self.client.get(f'/api/user/{self.user.pk}/', **{'Authorization': f'Bearer {token.token}'})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
# Create membership to validate permissions
|
||||
NoteUser.objects.create(user=self.user)
|
||||
membership = Membership.objects.create(user=self.user, club_id=bde.pk)
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
|
||||
membership.save()
|
||||
|
||||
# User is now a member and can now see its own user detail
|
||||
resp = self.client.get(f'/api/user/{self.user.pk}/', **{'Authorization': f'Bearer {token.token}'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Token is not granted to see profile detail
|
||||
resp = self.client.get(f'/api/members/profile/{self.user.profile.pk}/',
|
||||
**{'Authorization': f'Bearer {token.token}'})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_scopes(self):
|
||||
"""
|
||||
Ensure that the scopes page is loading.
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
resp = self.client.get(reverse('permission:scopes'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(self.application, resp.context['scopes'])
|
||||
self.assertNotIn('1_1', resp.context['scopes'][self.application]) # The user has not this permission
|
||||
|
||||
# Create membership to validate permissions
|
||||
bde = Club.objects.get(name="BDE")
|
||||
NoteUser.objects.create(user=self.user)
|
||||
membership = Membership.objects.create(user=self.user, club_id=bde.pk)
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
|
||||
membership.save()
|
||||
|
||||
resp = self.client.get(reverse('permission:scopes'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(self.application, resp.context['scopes'])
|
||||
self.assertIn('1_1', resp.context['scopes'][self.application]) # Now the user has this permission
|
@ -1,10 +1,17 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import path
|
||||
from permission.views import RightsView
|
||||
|
||||
from .views import RightsView, ScopesView
|
||||
|
||||
app_name = 'permission'
|
||||
urlpatterns = [
|
||||
path('rights', RightsView.as_view(), name="rights"),
|
||||
path('rights/', RightsView.as_view(), name="rights"),
|
||||
]
|
||||
|
||||
if "oauth2_provider" in settings.INSTALLED_APPS:
|
||||
urlpatterns += [
|
||||
path('scopes/', ScopesView.as_view(), name="scopes"),
|
||||
]
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
@ -28,7 +28,7 @@ class ProtectQuerysetMixin:
|
||||
"""
|
||||
def get_queryset(self, filter_permissions=True, **kwargs):
|
||||
qs = super().get_queryset(**kwargs)
|
||||
return qs.filter(PermissionBackend.filter_queryset(self.request.user, qs.model, "view")).distinct()\
|
||||
return qs.filter(PermissionBackend.filter_queryset(self.request, qs.model, "view")).distinct()\
|
||||
if filter_permissions else qs
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
@ -53,7 +53,7 @@ class ProtectQuerysetMixin:
|
||||
# We could also delete the field, but some views might be affected.
|
||||
meta = form.instance._meta
|
||||
for key in form.base_fields:
|
||||
if not PermissionBackend.check_perm(self.request.user,
|
||||
if not PermissionBackend.check_perm(self.request,
|
||||
f"{meta.app_label}.change_{meta.model_name}_" + key, self.object):
|
||||
form.fields[key].widget = HiddenInput()
|
||||
|
||||
@ -101,7 +101,7 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView):
|
||||
# noinspection PyProtectedMember
|
||||
app_label, model_name = model_class._meta.app_label, model_class._meta.model_name.lower()
|
||||
perm = app_label + ".add_" + model_name
|
||||
if not PermissionBackend.check_perm(request.user, perm, self.get_sample_object()):
|
||||
if not PermissionBackend.check_perm(request, perm, self.get_sample_object()):
|
||||
raise PermissionDenied(_("You don't have the permission to add an instance of model "
|
||||
"{app_label}.{model_name}.").format(app_label=app_label, model_name=model_name))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@ -131,8 +131,8 @@ class RightsView(TemplateView):
|
||||
special_memberships = Membership.objects.filter(
|
||||
date_start__lte=date.today(),
|
||||
date_end__gte=date.today(),
|
||||
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent BDE")
|
||||
| Q(name="Adhérent Kfet")
|
||||
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent⋅e BDE")
|
||||
| Q(name="Adhérent⋅e Kfet")
|
||||
| Q(name="Membre de club")
|
||||
| Q(name="Bureau de club"))
|
||||
& Q(weirole__isnull=True))))\
|
||||
@ -143,3 +143,26 @@ class RightsView(TemplateView):
|
||||
prefix="superusers-")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ScopesView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "permission/scopes.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
from oauth2_provider.models import Application
|
||||
from .scopes import PermissionScopes
|
||||
|
||||
scopes = PermissionScopes()
|
||||
context["scopes"] = {}
|
||||
all_scopes = scopes.get_all_scopes()
|
||||
for app in Application.objects.filter(user=self.request.user).all():
|
||||
available_scopes = scopes.get_available_scopes(app)
|
||||
context["scopes"][app] = OrderedDict()
|
||||
items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes]
|
||||
items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
|
||||
for k, v in items:
|
||||
context["scopes"][app][k] = v
|
||||
|
||||
return context
|
||||
|
@ -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,
|
||||
|
@ -66,9 +66,11 @@ class UserCreateView(CreateView):
|
||||
profile_form.instance.user = user
|
||||
profile = profile_form.save(commit=False)
|
||||
user.profile = profile
|
||||
user._force_save = True
|
||||
user.save()
|
||||
user.refresh_from_db()
|
||||
profile.user = user
|
||||
profile._force_save = True
|
||||
profile.save()
|
||||
|
||||
user.profile.send_email_validation_link()
|
||||
@ -83,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')
|
||||
|
||||
|
||||
@ -110,7 +115,9 @@ class UserValidateView(TemplateView):
|
||||
self.validlink = True
|
||||
user.is_active = user.profile.registration_valid or user.is_superuser
|
||||
user.profile.email_confirmed = True
|
||||
user._force_save = True
|
||||
user.save()
|
||||
user.profile._force_save = True
|
||||
user.profile.save()
|
||||
return self.render_to_response(self.get_context_data(), status=200 if self.validlink else 400)
|
||||
|
||||
@ -230,9 +237,6 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
|
||||
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
|
||||
kfet = Club.objects.get(name="Kfet")
|
||||
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
|
||||
# In 2020, for COVID-19 reasons, the BDE offered 80 € to each new member that opens a Sogé account,
|
||||
# since there is no WEI.
|
||||
fee += 8000
|
||||
ctx["total_fee"] = "{:.02f}".format(fee / 100, )
|
||||
|
||||
ctx["declare_soge_account"] = SogeCredit.objects.filter(user=user).exists()
|
||||
@ -348,7 +352,7 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
|
||||
membership._soge = True
|
||||
membership.save()
|
||||
membership.refresh_from_db()
|
||||
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
|
||||
membership.save()
|
||||
|
||||
if join_kfet:
|
||||
@ -362,7 +366,7 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
|
||||
membership._soge = True
|
||||
membership.save()
|
||||
membership.refresh_from_db()
|
||||
membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e Kfet"))
|
||||
membership.save()
|
||||
|
||||
if soge:
|
||||
@ -387,7 +391,7 @@ class FutureUserInvalidateView(ProtectQuerysetMixin, LoginRequiredMixin, View):
|
||||
Delete the pre-registered user which id is given in the URL.
|
||||
"""
|
||||
user = User.objects.filter(profile__registration_valid=False)\
|
||||
.filter(PermissionBackend.filter_queryset(request.user, User, "change", "is_valid"))\
|
||||
.filter(PermissionBackend.filter_queryset(request, User, "change", "is_valid"))\
|
||||
.get(pk=self.kwargs["pk"])
|
||||
# Delete associated soge credits before
|
||||
SogeCredit.objects.filter(user=user).delete()
|
||||
|
Submodule apps/scripts updated: 7a022b9407...c4f128786d
@ -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,13 +305,60 @@ 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()) + 8000
|
||||
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=datetime.date.today().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)
|
||||
|
||||
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):
|
||||
"""
|
||||
Invalidating a Société générale delete the transaction of the bank if it was already created.
|
||||
Treasurers must know what they do, With Great Power Comes Great Responsibility...
|
||||
Treasurers must know what they do, With Great Power Comes Great Responsibility…
|
||||
"""
|
||||
if self.valid:
|
||||
self.credit_transaction.valid = False
|
||||
@ -365,13 +415,14 @@ 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):
|
||||
"""
|
||||
Deleting a SogeCredit is equivalent to say that the Société générale didn't pay.
|
||||
Treasurers must know what they do, this is difficult to undo this operation.
|
||||
With Great Power Comes Great Responsibility...
|
||||
With Great Power Comes Great Responsibility…
|
||||
"""
|
||||
|
||||
total_fee = sum(transaction.total for transaction in self.transactions.all() if not transaction.valid)
|
||||
@ -392,6 +443,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,11 +53,32 @@ 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");
|
||||
@ -69,6 +96,22 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
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.user, "treasury.add_invoice", sample_invoice):
|
||||
if not PermissionBackend.check_perm(self.request, "treasury.add_invoice", sample_invoice):
|
||||
raise PermissionDenied(_("You are not able to see the treasury interface."))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@ -194,7 +195,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request, **kwargs):
|
||||
pk = kwargs["pk"]
|
||||
invoice = Invoice.objects.filter(PermissionBackend.filter_queryset(request.user, Invoice, "view")).get(pk=pk)
|
||||
invoice = Invoice.objects.filter(PermissionBackend.filter_queryset(request, Invoice, "view")).get(pk=pk)
|
||||
tex = invoice.tex
|
||||
|
||||
try:
|
||||
@ -259,7 +260,7 @@ class RemittanceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
context["table"] = RemittanceTable(
|
||||
data=Remittance.objects.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all())
|
||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all())
|
||||
context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
|
||||
|
||||
return context
|
||||
@ -281,7 +282,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
|
||||
remittance_type_id=1,
|
||||
comment="",
|
||||
)
|
||||
if not PermissionBackend.check_perm(self.request.user, "treasury.add_remittance", sample_remittance):
|
||||
if not PermissionBackend.check_perm(self.request, "treasury.add_remittance", sample_remittance):
|
||||
raise PermissionDenied(_("You are not able to see the treasury interface."))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@ -290,7 +291,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
opened_remittances = RemittanceTable(
|
||||
data=Remittance.objects.filter(closed=False).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
|
||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
|
||||
prefix="opened-remittances-",
|
||||
)
|
||||
opened_remittances.paginate(page=self.request.GET.get("opened-remittances-page", 1), per_page=10)
|
||||
@ -298,7 +299,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
closed_remittances = RemittanceTable(
|
||||
data=Remittance.objects.filter(closed=True).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
|
||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
|
||||
prefix="closed-remittances-",
|
||||
)
|
||||
closed_remittances.paginate(page=self.request.GET.get("closed-remittances-page", 1), per_page=10)
|
||||
@ -307,7 +308,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
|
||||
no_remittance_tr = SpecialTransactionTable(
|
||||
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
|
||||
specialtransactionproxy__remittance=None).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
|
||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
|
||||
exclude=('remittance_remove', ),
|
||||
prefix="no-remittance-",
|
||||
)
|
||||
@ -317,7 +318,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
|
||||
with_remittance_tr = SpecialTransactionTable(
|
||||
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
|
||||
specialtransactionproxy__remittance__closed=False).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(),
|
||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
|
||||
exclude=('remittance_add', ),
|
||||
prefix="with-remittance-",
|
||||
)
|
||||
@ -342,7 +343,7 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView)
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all()
|
||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all()
|
||||
context["special_transactions"] = SpecialTransactionTable(
|
||||
data=data,
|
||||
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
|
||||
@ -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', )
|
||||
@ -36,11 +45,10 @@ class WEIRegistrationForm(forms.ModelForm):
|
||||
attrs={
|
||||
'api_url': '/api/user/',
|
||||
'name_field': 'username',
|
||||
'placeholder': 'Nom ...',
|
||||
'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'}),
|
||||
}
|
||||
|
||||
@ -66,7 +74,7 @@ class WEIChooseBusForm(forms.Form):
|
||||
queryset=WEIRole.objects.filter(~Q(name="1A")),
|
||||
label=_("WEI Roles"),
|
||||
help_text=_("Select the roles that you are interested in."),
|
||||
initial=WEIRole.objects.filter(name="Adhérent WEI").all(),
|
||||
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
|
||||
widget=CheckboxSelectMultiple(),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -121,20 +130,34 @@ class WEIMembershipForm(forms.ModelForm):
|
||||
Bus,
|
||||
attrs={
|
||||
'api_url': '/api/wei/bus/',
|
||||
'placeholder': 'Bus ...',
|
||||
'placeholder': 'Bus…',
|
||||
}
|
||||
),
|
||||
"team": Autocomplete(
|
||||
BusTeam,
|
||||
attrs={
|
||||
'api_url': '/api/wei/team/',
|
||||
'placeholder': 'Équipe ...',
|
||||
'placeholder': 'Équipe…',
|
||||
},
|
||||
resetable=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@ -144,7 +167,7 @@ class BusForm(forms.ModelForm):
|
||||
WEIClub,
|
||||
attrs={
|
||||
'api_url': '/api/wei/club/',
|
||||
'placeholder': 'WEI ...',
|
||||
'placeholder': 'WEI…',
|
||||
},
|
||||
),
|
||||
}
|
||||
@ -159,7 +182,7 @@ class BusTeamForm(forms.ModelForm):
|
||||
Bus,
|
||||
attrs={
|
||||
'api_url': '/api/wei/bus/',
|
||||
'placeholder': 'Bus ...',
|
||||
'placeholder': 'Bus…',
|
||||
},
|
||||
),
|
||||
"color": ColorWidget(),
|
||||
|
@ -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
|
||||
|
@ -1,12 +1,12 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Form
|
||||
|
||||
from ...models import WEIClub, WEIRegistration, Bus
|
||||
from ...models import WEIClub, WEIRegistration, Bus, WEIMembership
|
||||
|
||||
|
||||
class WEISurveyInformation:
|
||||
@ -50,6 +50,20 @@ class WEIBusInformation:
|
||||
self.bus.information = d
|
||||
self.bus.save()
|
||||
|
||||
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 quota - valid_surveys
|
||||
|
||||
def has_free_seats(self, surveys=None, quotas=None):
|
||||
return self.free_seats(surveys, quotas) > 0
|
||||
|
||||
|
||||
class WEISurveyAlgorithm:
|
||||
"""
|
||||
@ -76,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())
|
||||
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):
|
||||
@ -125,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):
|
||||
@ -192,3 +215,23 @@ class WEISurvey:
|
||||
self.information.selected_bus_pk = bus.pk
|
||||
self.information.selected_bus_name = bus.name
|
||||
self.information.valid = True
|
||||
|
||||
def free(self) -> None:
|
||||
"""
|
||||
Unselect the select bus.
|
||||
"""
|
||||
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,14 +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 Bus
|
||||
from ...models import WEIMembership
|
||||
|
||||
WORDS = [
|
||||
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
|
||||
@ -41,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 _ in range(information.step + 1):
|
||||
# Generate N times words
|
||||
words = [rng.choice(WORDS) for _ 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
|
||||
|
||||
|
||||
@ -124,12 +139,46 @@ 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)
|
||||
# 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):
|
||||
"""
|
||||
The algorithm class for the year 2021.
|
||||
For now, the algorithm is quite simple: the selected bus is the chosen bus.
|
||||
TODO: Improve this algorithm.
|
||||
We use Gale-Shapley algorithm to attribute 1y students into buses.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@ -140,9 +189,105 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm):
|
||||
def get_bus_information_class(cls):
|
||||
return WEIBusInformation2021
|
||||
|
||||
def run_algorithm(self):
|
||||
for registration in self.get_registrations():
|
||||
survey = self.get_survey_class()(registration)
|
||||
rng = Random(survey.information.seed)
|
||||
survey.select_bus(rng.choice(Bus.objects.all()))
|
||||
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
|
||||
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, 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()
|
||||
|
293
apps/wei/forms/surveys/wei2022.py
Normal file
293
apps/wei/forms/surveys/wei2022.py
Normal file
@ -0,0 +1,293 @@
|
||||
# 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 = [
|
||||
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
|
||||
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
|
||||
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
|
||||
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
|
||||
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
|
||||
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
|
||||
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
|
||||
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
|
||||
]
|
||||
|
||||
|
||||
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()
|
@ -84,5 +84,5 @@ class Command(BaseCommand):
|
||||
s += sep + user.profile.section_generated
|
||||
s += sep + bus.name
|
||||
s += sep + (team.name if team else "--")
|
||||
s += sep + ", ".join(role.name for role in membership.roles.filter(~Q(name="Adhérent WEI")).all())
|
||||
s += sep + ", ".join(role.name for role in membership.roles.filter(~Q(name="Adhérent⋅e WEI")).all())
|
||||
self.stdout.write(s)
|
||||
|
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!")
|
@ -5,7 +5,7 @@ from argparse import ArgumentParser, FileType
|
||||
from django.core.management import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from wei.forms import CurrentSurvey
|
||||
from ...forms import CurrentSurvey
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -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
|
||||
|
||||
@ -145,7 +153,7 @@ class BusTeam(models.Model):
|
||||
|
||||
class WEIRole(Role):
|
||||
"""
|
||||
A Role for the WEI can be bus chief, team chief, free electron, ...
|
||||
A Role for the WEI can be bus chief, team chief, free electron,…
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -250,7 +258,7 @@ class WEIRegistration(models.Model):
|
||||
@property
|
||||
def information(self):
|
||||
"""
|
||||
The information about the registration (the survey for the new members, the bus for the older members, ...)
|
||||
The information about the registration (the survey for the new members, the bus for the older members,…)
|
||||
are stored in a dictionary that can evolve following the years. The dictionary is stored as a JSON string.
|
||||
"""
|
||||
return json.loads(self.information_json)
|
||||
@ -262,6 +270,33 @@ class WEIRegistration(models.Model):
|
||||
"""
|
||||
self.information_json = json.dumps(information, indent=2)
|
||||
|
||||
@property
|
||||
def fee(self):
|
||||
bde = Club.objects.get(pk=1)
|
||||
kfet = Club.objects.get(pk=2)
|
||||
|
||||
kfet_member = Membership.objects.filter(
|
||||
club_id=kfet.id,
|
||||
user=self.user,
|
||||
date_start__gte=kfet.membership_start,
|
||||
).exists()
|
||||
bde_member = Membership.objects.filter(
|
||||
club_id=bde.id,
|
||||
user=self.user,
|
||||
date_start__gte=bde.membership_start,
|
||||
).exists()
|
||||
|
||||
fee = self.wei.membership_fee_paid if self.user.profile.paid \
|
||||
else self.wei.membership_fee_unpaid
|
||||
if not kfet_member:
|
||||
fee += kfet.membership_fee_paid if self.user.profile.paid \
|
||||
else kfet.membership_fee_unpaid
|
||||
if not bde_member:
|
||||
fee += bde.membership_fee_paid if self.user.profile.paid \
|
||||
else bde.membership_fee_unpaid
|
||||
|
||||
return fee
|
||||
|
||||
@property
|
||||
def is_validated(self):
|
||||
try:
|
||||
@ -337,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,11 +4,12 @@
|
||||
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 _
|
||||
from django_tables2 import A
|
||||
from note_kfet.middlewares import get_current_authenticated_user
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership
|
||||
@ -43,6 +44,7 @@ class WEIRegistrationTable(tables.Table):
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
'wei:wei_update_registration',
|
||||
orderable=False,
|
||||
args=[A('pk')],
|
||||
verbose_name=_("Edit"),
|
||||
text=_("Edit"),
|
||||
@ -53,18 +55,14 @@ class WEIRegistrationTable(tables.Table):
|
||||
}
|
||||
}
|
||||
)
|
||||
validate = tables.LinkColumn(
|
||||
'wei:validate_registration',
|
||||
args=[A('pk')],
|
||||
|
||||
validate = tables.Column(
|
||||
verbose_name=_("Validate"),
|
||||
text=_("Validate"),
|
||||
orderable=False,
|
||||
accessor=A('pk'),
|
||||
attrs={
|
||||
'th': {
|
||||
'id': 'validate-membership-header'
|
||||
},
|
||||
'a': {
|
||||
'class': 'btn btn-success',
|
||||
'data-type': 'validate-membership'
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -72,6 +70,7 @@ class WEIRegistrationTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
'wei:wei_delete_registration',
|
||||
args=[A('pk')],
|
||||
orderable=False,
|
||||
verbose_name=_("delete"),
|
||||
text=_("Delete"),
|
||||
attrs={
|
||||
@ -87,7 +86,7 @@ class WEIRegistrationTable(tables.Table):
|
||||
|
||||
def render_validate(self, record):
|
||||
hasperm = PermissionBackend.check_perm(
|
||||
get_current_authenticated_user(), "wei.add_weimembership", WEIMembership(
|
||||
get_current_request(), "wei.add_weimembership", WEIMembership(
|
||||
club=record.wei,
|
||||
user=record.user,
|
||||
date_start=date.today(),
|
||||
@ -96,10 +95,26 @@ class WEIRegistrationTable(tables.Table):
|
||||
registration=record,
|
||||
)
|
||||
)
|
||||
return _("Validate") if hasperm else format_html("<span class='no-perm'></span>")
|
||||
if not hasperm:
|
||||
return format_html("<span class='no-perm'></span>")
|
||||
|
||||
url = reverse_lazy('wei:validate_registration', args=(record.pk,))
|
||||
text = _('Validate')
|
||||
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.")
|
||||
|
||||
return format_html(f"<a class=\"btn {btn_class}\" data-type='validate-membership' data-toggle=\"tooltip\" "
|
||||
f"title=\"{tooltip}\" href=\"{url}\">{text}</a>")
|
||||
|
||||
def render_delete(self, record):
|
||||
hasperm = PermissionBackend.check_perm(get_current_authenticated_user(), "wei.delete_weimembership", record)
|
||||
hasperm = PermissionBackend.check_perm(get_current_request(), "wei.delete_weimembership", record)
|
||||
return _("Delete") if hasperm else format_html("<span class='no-perm'></span>")
|
||||
|
||||
class Meta:
|
||||
@ -108,7 +123,8 @@ class WEIRegistrationTable(tables.Table):
|
||||
}
|
||||
model = WEIRegistration
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'caution_check',)
|
||||
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'caution_check',
|
||||
'edit', 'validate', 'delete',)
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'id': lambda record: "row-" + str(record.pk),
|
||||
@ -154,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',
|
||||
@ -230,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>
|
||||
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% block profile_content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note/bus/équipe ...">
|
||||
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note/bus/équipe…">
|
||||
<hr>
|
||||
|
||||
<div id="memberships_table">
|
||||
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
<div class="card-footer text-center">
|
||||
<a href="{% url 'wei:wei_registrations' pk=club.pk %}">
|
||||
<button class="btn btn-block btn-info">{% trans "View unvalidated registrations..." %}</button>
|
||||
<button class="btn btn-block btn-info">{% trans "View unvalidated registrations…" %}</button>
|
||||
</a>
|
||||
<hr>
|
||||
<a href="{% url 'wei:wei_memberships_pdf' wei_pk=club.pk %}" data-turbolinks="false">
|
||||
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% block profile_content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
|
||||
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note…">
|
||||
<hr>
|
||||
|
||||
<div id="registrations_table">
|
||||
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
<div class="card-footer text-center">
|
||||
<a href="{% url 'wei:wei_memberships' pk=club.pk %}">
|
||||
<button class="btn btn-block btn-info">{% trans "View validated memberships..." %}</button>
|
||||
<button class="btn btn-block btn-info">{% trans "View validated memberships…" %}</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
110
apps/wei/tests/test_wei_algorithm_2021.py
Normal file
110
apps/wei/tests/test_wei_algorithm_2021.py
Normal file
@ -0,0 +1,110 @@
|
||||
# Copyright (C) 2018-2021 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.wei2021 import WEIBusInformation2021, WEISurvey2021, WORDS, WEISurveyInformation2021
|
||||
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 2021",
|
||||
email="wei2021@example.com",
|
||||
date_start='2021-09-17',
|
||||
date_end='2021-09-19',
|
||||
year=2021,
|
||||
)
|
||||
|
||||
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 = WEIBusInformation2021(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 = WEISurveyInformation2021(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
|
||||
WEISurvey2021.get_algorithm_class()().run_algorithm()
|
||||
|
||||
# Ensure that everyone has its first choice
|
||||
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||
survey = WEISurvey2021(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 = WEISurveyInformation2021(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
|
||||
WEISurvey2021.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 = WEISurvey2021(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 %
|
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,
|
||||
@ -188,7 +195,9 @@ class TestWEIRegistration(TestCase):
|
||||
response = self.client.post(reverse("wei:add_bus", kwargs=dict(pk=self.wei.pk)), dict(
|
||||
wei=self.wei.id,
|
||||
name="Create Bus Test",
|
||||
size=50,
|
||||
description="This bus was created.",
|
||||
information_json="{}",
|
||||
))
|
||||
qs = Bus.objects.filter(name="Create Bus Test")
|
||||
self.assertTrue(qs.exists())
|
||||
@ -218,7 +227,9 @@ class TestWEIRegistration(TestCase):
|
||||
|
||||
response = self.client.post(reverse("wei:update_bus", kwargs=dict(pk=self.bus.pk)), dict(
|
||||
name="Update Bus Test",
|
||||
size=40,
|
||||
description="This bus was updated.",
|
||||
information_json="{}",
|
||||
))
|
||||
qs = Bus.objects.filter(name="Update Bus Test", id=self.bus.id)
|
||||
self.assertRedirects(response, reverse("wei:manage_bus", kwargs=dict(pk=self.bus.pk)), 302, 200)
|
||||
@ -291,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(
|
||||
@ -357,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
|
||||
@ -373,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,
|
||||
@ -456,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.
|
||||
@ -491,7 +519,7 @@ class TestWEIRegistration(TestCase):
|
||||
emergency_contact_phone='+33600000000',
|
||||
bus=[self.bus.id],
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent WEI").all()],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||
information_json=self.registration.information_json,
|
||||
)
|
||||
)
|
||||
@ -545,7 +573,7 @@ class TestWEIRegistration(TestCase):
|
||||
emergency_contact_phone='+33600000000',
|
||||
bus=[self.bus.id],
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent WEI").all()],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||
information_json=self.registration.information_json,
|
||||
)
|
||||
)
|
||||
@ -754,62 +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()
|
||||
|
||||
def test_survey_algorithm(self):
|
||||
CurrentSurvey.get_algorithm_class()().run_algorithm()
|
||||
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):
|
||||
@ -57,7 +59,7 @@ class WEIListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["can_create_wei"] = PermissionBackend.check_perm(self.request.user, "wei.add_weiclub", WEIClub(
|
||||
context["can_create_wei"] = PermissionBackend.check_perm(self.request, "wei.add_weiclub", WEIClub(
|
||||
name="",
|
||||
email="weiclub@example.com",
|
||||
year=0,
|
||||
@ -112,7 +114,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
club = context["club"]
|
||||
|
||||
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")) \
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \
|
||||
.order_by('-created_at', '-id')
|
||||
history_table = HistoryTable(club_transactions, prefix="history-")
|
||||
history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
|
||||
@ -121,18 +123,18 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
club_member = WEIMembership.objects.filter(
|
||||
club=club,
|
||||
date_end__gte=date.today(),
|
||||
).filter(PermissionBackend.filter_queryset(self.request.user, WEIMembership, "view"))
|
||||
).filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view"))
|
||||
membership_table = WEIMembershipTable(data=club_member, prefix="membership-")
|
||||
membership_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1))
|
||||
context['member_list'] = membership_table
|
||||
|
||||
pre_registrations = WEIRegistration.objects.filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, WEIRegistration, "view")).filter(
|
||||
PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter(
|
||||
membership=None,
|
||||
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)
|
||||
@ -142,7 +144,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
my_registration = None
|
||||
context["my_registration"] = my_registration
|
||||
|
||||
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request.user, Bus, "view")) \
|
||||
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
|
||||
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
|
||||
bus_table = BusTable(data=buses, prefix="bus-")
|
||||
context['buses'] = bus_table
|
||||
@ -167,7 +169,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
emergency_contact_phone="No",
|
||||
)
|
||||
context["can_add_first_year_member"] = PermissionBackend \
|
||||
.check_perm(self.request.user, "wei.add_weiregistration", empty_fy_registration)
|
||||
.check_perm(self.request, "wei.add_weiregistration", empty_fy_registration)
|
||||
|
||||
# Check if the user has the right to create a registration of a random old member.
|
||||
empty_old_registration = WEIRegistration(
|
||||
@ -180,16 +182,20 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
emergency_contact_phone="No",
|
||||
)
|
||||
context["can_add_any_member"] = PermissionBackend \
|
||||
.check_perm(self.request.user, "wei.add_weiregistration", empty_old_registration)
|
||||
.check_perm(self.request, "wei.add_weiregistration", empty_old_registration)
|
||||
|
||||
empty_bus = Bus(
|
||||
wei=club,
|
||||
name="",
|
||||
)
|
||||
context["can_add_bus"] = PermissionBackend.check_perm(self.request.user, "wei.add_bus", empty_bus)
|
||||
context["can_add_bus"] = PermissionBackend.check_perm(self.request, "wei.add_bus", empty_bus)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -222,7 +228,7 @@ class WEIMembershipsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
|
||||
| Q(team__name__iregex=pattern)
|
||||
)
|
||||
|
||||
return qs[:20]
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
@ -256,7 +262,7 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable
|
||||
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
||||
)
|
||||
|
||||
return qs[:20]
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
@ -370,13 +376,13 @@ class BusManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
context["club"] = self.object.wei
|
||||
|
||||
bus = self.object
|
||||
teams = BusTeam.objects.filter(PermissionBackend.filter_queryset(self.request.user, BusTeam, "view")) \
|
||||
teams = BusTeam.objects.filter(PermissionBackend.filter_queryset(self.request, BusTeam, "view")) \
|
||||
.filter(bus=bus).annotate(count=Count("memberships")).order_by("name")
|
||||
teams_table = BusTeamTable(data=teams, prefix="team-")
|
||||
context["teams"] = teams_table
|
||||
|
||||
memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset(
|
||||
self.request.user, WEIMembership, "view")).filter(bus=bus)
|
||||
self.request, WEIMembership, "view")).filter(bus=bus)
|
||||
memberships_table = WEIMembershipTable(data=memberships, prefix="membership-")
|
||||
memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1))
|
||||
context["memberships"] = memberships_table
|
||||
@ -469,7 +475,7 @@ class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
context["club"] = self.object.bus.wei
|
||||
|
||||
memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset(
|
||||
self.request.user, WEIMembership, "view")).filter(team=self.object)
|
||||
self.request, WEIMembership, "view")).filter(team=self.object)
|
||||
memberships_table = WEIMembershipTable(data=memberships, prefix="membership-")
|
||||
memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1))
|
||||
context["memberships"] = memberships_table
|
||||
@ -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.user, "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.user, "wei.change_weiregistration_information_json", self.object):
|
||||
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,21 +725,35 @@ 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()
|
||||
# If it is not validated and if this is an old member, then we update the choices
|
||||
elif not form.instance.first_year and PermissionBackend.check_perm(
|
||||
self.request.user, "wei.change_weiregistration_information_json", self.object):
|
||||
self.request, "wei.change_weiregistration_information_json", self.object):
|
||||
choose_bus_form = WEIChooseBusForm(self.request.POST)
|
||||
if not choose_bus_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
@ -726,7 +775,7 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
survey = CurrentSurvey(self.object)
|
||||
if not survey.is_complete():
|
||||
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
|
||||
if PermissionBackend.check_perm(self.request.user, "wei.add_weimembership", WEIMembership(
|
||||
if PermissionBackend.check_perm(self.request, "wei.add_weimembership", WEIMembership(
|
||||
club=self.object.wei,
|
||||
user=self.object.user,
|
||||
date_start=date.today(),
|
||||
@ -753,7 +802,7 @@ class WEIDeleteRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Delete
|
||||
if today > wei.membership_end:
|
||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||
|
||||
if not PermissionBackend.check_perm(self.request.user, "wei.delete_weiregistration", object):
|
||||
if not PermissionBackend.check_perm(self.request, "wei.delete_weiregistration", object):
|
||||
raise PermissionDenied(_("You don't have the right to delete this WEI registration."))
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@ -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):
|
||||
@ -818,25 +866,22 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
date_start__gte=bde.membership_start,
|
||||
).exists()
|
||||
|
||||
fee = registration.wei.membership_fee_paid if registration.user.profile.paid \
|
||||
else registration.wei.membership_fee_unpaid
|
||||
if not context["kfet_member"]:
|
||||
fee += kfet.membership_fee_paid if registration.user.profile.paid \
|
||||
else kfet.membership_fee_unpaid
|
||||
if not context["bde_member"]:
|
||||
fee += bde.membership_fee_paid if registration.user.profile.paid \
|
||||
else bde.membership_fee_unpaid
|
||||
|
||||
context["fee"] = fee
|
||||
context["fee"] = registration.fee
|
||||
|
||||
form = context["form"]
|
||||
if registration.soge_credit:
|
||||
form.fields["credit_amount"].initial = fee
|
||||
form.fields["credit_amount"].initial = registration.fee
|
||||
else:
|
||||
form.fields["credit_amount"].initial = max(0, fee - registration.user.note.balance)
|
||||
form.fields["credit_amount"].initial = max(0, registration.fee - registration.user.note.balance)
|
||||
|
||||
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"])
|
||||
@ -852,6 +897,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
form.fields["bank"].disabled = True
|
||||
form.fields["bank"].initial = "Société générale"
|
||||
|
||||
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
|
||||
@ -869,7 +916,7 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
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")
|
||||
Q(pk__in=information["preferred_roles_pk"]) | Q(name="Adhérent⋅e WEI")
|
||||
).all()
|
||||
return form
|
||||
|
||||
@ -959,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"))
|
||||
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
|
||||
|
||||
return ret
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
self.object.refresh_from_db()
|
||||
@ -1058,7 +1104,7 @@ class MemberListRenderView(LoginRequiredMixin, View):
|
||||
"""
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
qs = WEIMembership.objects.filter(PermissionBackend.filter_queryset(self.request.user, WEIMembership, "view"))
|
||||
qs = WEIMembership.objects.filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view"))
|
||||
qs = qs.filter(club__pk=self.kwargs["wei_pk"]).order_by(
|
||||
Lower('bus__name'),
|
||||
Lower('team__name'),
|
||||
@ -1131,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, ))
|
||||
|
BIN
docs/_static/img/create_transaction.png
vendored
BIN
docs/_static/img/create_transaction.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 150 KiB |
@ -23,18 +23,18 @@ Pages de l'API
|
||||
Il suffit d'ajouter le préfixe ``/api/`` pour arriver sur ces pages.
|
||||
|
||||
* `models <basic#type-de-contenu>`_ : liste des différents modèles enregistrés en base de données
|
||||
* `user <basic#utilisateur>`_ : liste des différents utilisateurs enregistrés
|
||||
* `user <basic#utilisateur>`_ : liste des différent⋅es utilisateur⋅rices enregistrés
|
||||
* `members/profile <member#profil-utilisateur>`_ : liste des différents profils associés à des utilisateurs
|
||||
* `members/club <member#club>`_ : liste des différents clubs enregistrés
|
||||
* `members/membership <member#adhesion>`_ : liste des adhésions enregistrées
|
||||
* `activity/activity <activity#activite>`_ : liste des activités recensées
|
||||
* `activity/type <activity#type-d-activite>`_ : liste des différents types d'activités : pots, soirées de club, ...
|
||||
* `activity/type <activity#type-d-activite>`_ : liste des différents types d'activités : pots, soirées de club,…
|
||||
* `activity/guest <activity#invite>`_ : liste des personnes invitées lors d'une activité
|
||||
* `activity/entry <activity#entree>`_ : liste des entrées effectuées lors des activités
|
||||
* `note/note <note#note>`_ : liste des notes enregistrées
|
||||
* `note/alias <note#alias>`_ : liste des alias enregistrés
|
||||
* `note/consumer <note#consommateur>`_ : liste des alias enregistrés avec leur note associée
|
||||
* `note/transaction/category <note#categorie-de-transaction>`_ : liste des différentes catégories de boutons : soft, alcool, ...
|
||||
* `note/transaction/category <note#categorie-de-transaction>`_ : liste des différentes catégories de boutons : soft, alcool,…
|
||||
* `note/transaction/transaction <note#transaction>`_ : liste des transactions effectuées
|
||||
* `note/transaction/template <note#modele-de-transaction>`_ : liste des boutons enregistrés
|
||||
* `treasury/invoice <treasury#facture>`_ : liste des factures générées
|
||||
@ -69,7 +69,7 @@ S'authentifier
|
||||
|
||||
L'authentification peut se faire soit par session en se connectant via la page de connexion classique,
|
||||
soit via un jeton d'authentification. Le jeton peut se récupérer via la page de son propre compte, en cliquant
|
||||
sur le bouton « `Accès API <https://note.crans.org/accounts/manage-auth-token/>`_ ». Il peut être révoqué et regénéré
|
||||
sur le bouton « `Accès API <https://note.crans.org/accounts/manage-auth-token/>`_ ». Il peut être révoqué et régénéré
|
||||
en un clic.
|
||||
|
||||
Pour s'authentifier via ce jeton, il faut ajouter l'en-tête ``Authorization: Token <TOKEN>`` aux paramètres HTTP.
|
||||
@ -111,7 +111,7 @@ Trois types de filtres sont implémentés :
|
||||
|
||||
Les filtres disponibles sont indiqués sur chacune des pages de documentation.
|
||||
|
||||
Le résultat est déjà par défaut filtré par droits : seuls les éléments que l'utilisateur à le droit de voir sont affichés.
|
||||
Le résultat est déjà par défaut filtré par droits : seuls les éléments que l'utilisateur⋅rice a le droit de voir sont affichés.
|
||||
Cela est possible grâce à la structure des permissions, générant justement des filtres de requêtes de base de données.
|
||||
|
||||
Une requête à l'adresse ``/api/<model>/<pk>/`` affiche directement les informations du modèle demandé au format JSON.
|
||||
@ -120,14 +120,15 @@ POST
|
||||
~~~~
|
||||
|
||||
Une requête POST permet d'ajouter des éléments. Cette requête n'est possible que sur la page ``/api/<model>/``,
|
||||
la requête POST n'est pas supportée sur les pages de détails (car cette requête permet ... l'ajout).
|
||||
la requête POST n'est pas supportée sur les pages de détails (car cette requête permet… l'ajout).
|
||||
|
||||
Des exceptions sont faites sur certaines pages : les pages de logs et de contenttypes sont en lecture uniquement.
|
||||
|
||||
Les formats supportés sont multiples : ``application/json``, ``application/x-www-url-encoded``, ``multipart/form-data``.
|
||||
Cela facilite l'envoi de requêtes. Le module construit ensuite l'instance du modèle et le sauvegarde dans la base de
|
||||
données. L'application ``permission`` s'assure que l'utilisateur à le droit de faire ce type de modification. La réponse
|
||||
renvoyée est l'objet enregistré au format JSON si l'ajout s'est bien déroulé, sinon un message d'erreur au format JSON.
|
||||
données. L'application ``permission`` s'assure que l'utilisateur⋅rice a le droit de faire ce type de modification.
|
||||
La réponse renvoyée est l'objet enregistré au format JSON si l'ajout s'est bien déroulé, sinon un message d'erreur au
|
||||
format JSON.
|
||||
|
||||
PATCH
|
||||
~~~~~
|
||||
@ -205,10 +206,10 @@ Une reqête OPTIONS affiche l'ensemble des opérations possibles sur un modèle
|
||||
|
||||
* ``<METHOD>`` est le type de requête HTTP supporté (pour modification, inclus dans {``POST``, ``PUT``, ``PATCH``}).
|
||||
* ``<FIELD_NAME>`` est le nom du champ dans le modèle concerné (exemple : ``id``)
|
||||
* ``<TYPE>`` représente le type de données : ``integer``, ``string``, ``date``, ``choice``, ``field`` (pour les clés étrangères), ...
|
||||
* ``<TYPE>`` représente le type de données : ``integer``, ``string``, ``date``, ``choice``, ``field`` (pour les clés étrangères),…
|
||||
* ``<REQUIRED>`` est un booléen indiquant si le champ est requis dans le modèle ou s'il peut être nul/vide.
|
||||
* ``<READ_ONLY>`` est un booléen indiquant si le champ est accessible en lecture uniquement.
|
||||
* ``<LABEL>`` représente le label du champ, son nom traduit, qui s'affiche dans le formulaire accessible sur l'API Web.
|
||||
|
||||
Des contraintes peuvent s'ajouter à cela selon les champs : taille maximale de chaînes de caractères, valeurs minimales
|
||||
et maximales pour les entiers ...
|
||||
et maximales pour les entiers…
|
@ -135,7 +135,7 @@ Options
|
||||
"required": false,
|
||||
"read_only": false,
|
||||
"label": "Pay\u00e9",
|
||||
"help_text": "Indique si l'utilisateur per\u00e7oit un salaire."
|
||||
"help_text": "Indique si l'utilisateur⋅rice per\u00e7oit un salaire."
|
||||
},
|
||||
"ml_events_registration": {
|
||||
"type": "choice",
|
||||
|
@ -507,7 +507,7 @@ Options
|
||||
"required": false,
|
||||
"read_only": false,
|
||||
"label": "Premi\u00e8re ann\u00e9e",
|
||||
"help_text": "Indique si l'utilisateur est nouveau dans l'\u00e9cole."
|
||||
"help_text": "Indique si l'utilisateur⋅rice est nouvelleau dans l'\u00e9cole."
|
||||
},
|
||||
"information_json": {
|
||||
"type": "string",
|
||||
@ -520,7 +520,7 @@ Options
|
||||
"type": "field",
|
||||
"required": true,
|
||||
"read_only": false,
|
||||
"label": "Utilisateur"
|
||||
"label": "Utilisateur⋅rice"
|
||||
},
|
||||
"wei": {
|
||||
"type": "field",
|
||||
|
@ -3,20 +3,20 @@ Application Activités
|
||||
|
||||
L'application activités gère les différentes activités liées au BDE. Elle permet entre autres de créer des activités qui
|
||||
peuvent être diffusées via des calendriers ou la mailing list d'événements. Elle permet aussi de réguler l'accès aux
|
||||
événements, en s'assurant que leur note est positive. Elle permet enfin de gérer les invités.
|
||||
événements, en s'assurant que leur note est positive. Elle permet enfin de gérer les invité⋅es.
|
||||
|
||||
Modèles
|
||||
-------
|
||||
|
||||
L'application comporte 5 modèles : activités, types d'activité, invités, entrées et transactions d'invitation.
|
||||
L'application comporte 5 modèles : activités, types d'activité, invité⋅es, entrées et transactions d'invitation.
|
||||
|
||||
Types d'activité
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Les activités sont triées par type (pots, soirées de club, ...), et chaque type regroupe diverses informations :
|
||||
Les activités sont triées par type (pots, soirées de club,…), et chaque type regroupe diverses informations :
|
||||
|
||||
* Nom du type
|
||||
* Possibilité d'inviter des non-adhérents (booléen)
|
||||
* Possibilité d'inviter des non-adhérent⋅es (booléen)
|
||||
* Prix d'invitation (entier, centimes à débiter sur la note de l'hôte)
|
||||
|
||||
Activités
|
||||
@ -26,7 +26,7 @@ Le modèle d'activité regroupe les informations liées à l'activité même :
|
||||
|
||||
* Nom de l'activité
|
||||
* Description de l'activité
|
||||
* Créateur, personne qui a proposé l'activité
|
||||
* Créateur⋅rice, personne qui a proposé l'activité
|
||||
* Club ayant organisé l'activité
|
||||
* Note sur laquelle verser les crédits d'invitation (peut être nul si non concerné)
|
||||
* Club invité (généralement le club Kfet)
|
||||
@ -38,19 +38,19 @@ Le modèle d'activité regroupe les informations liées à l'activité même :
|
||||
Entrées
|
||||
~~~~~~~
|
||||
|
||||
Une instance de ce modèle est créé dès que quelqu'un est inscrit à l'activité. Sont stockées les informations suivantes :
|
||||
Une instance de ce modèle est créé dès que quelqu'un⋅e est inscrit⋅e à l'activité. Sont stockées les informations suivantes :
|
||||
|
||||
* Activité concernée (clé étrangère)
|
||||
* Heure d'entrée
|
||||
* Note de la personne entrée, ou hôte s'il s'agit d'un invité (clé étrangère vers ``NoteUser``)
|
||||
* Invité (``OneToOneField`` vers ``Guest``, ``None`` si c'est la personne elle-même qui rentre et non son invité)
|
||||
* Note de la personne entrée, ou hôte s'il s'agit d'un⋅e invité⋅e (clé étrangère vers ``NoteUser``)
|
||||
* Invité⋅e (``OneToOneField`` vers ``Guest``, ``None`` si c'est la personne elle-même qui rentre et non saon invité⋅e)
|
||||
|
||||
Il n'est pas possible de créer une entrée si la note est en négatif.
|
||||
|
||||
Invités
|
||||
~~~~~~~
|
||||
Invité⋅es
|
||||
~~~~~~~~~
|
||||
|
||||
Les adhérents ont la possibilité d'inviter des amis. Pour cela, les différentes informations sont enregistrées :
|
||||
Les adhérent⋅es ont la possibilité d'inviter des ami⋅es. Pour cela, les différentes informations sont enregistrées :
|
||||
|
||||
* Activité concernée (clé étrangère)
|
||||
* Nom de famille
|
||||
@ -60,7 +60,7 @@ Les adhérents ont la possibilité d'inviter des amis. Pour cela, les différent
|
||||
Certaines contraintes s'appliquent :
|
||||
|
||||
* Une personne ne peut pas être invitée plus de 5 fois par an (coupe nom/prénom)
|
||||
* Un adhérent ne peut pas inviter plus de 3 personnes par activité.
|
||||
* Un⋅e adhérent⋅e ne peut pas inviter plus de 3 personnes par activité.
|
||||
|
||||
Transactions d'invitation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@ -83,15 +83,15 @@ UI
|
||||
Création d'activités
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
N'importe quel adhérent Kfet peut suggérer l'ajout d'une activité via un formulaire.
|
||||
N'importe quel⋅le adhérent⋅e Kfet peut suggérer l'ajout d'une activité via un formulaire.
|
||||
|
||||
Gestion des activités
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Les ayant-droit (Res[pot] et respos infos) peuvent valider les activités proposées. Ils peuvent également la modifier
|
||||
si besoin. Ils peuvent enfin la déclarer ouvertes pour lancer l'accès aux entrées.
|
||||
si besoin. Iels peuvent enfin la déclarer ouverte pour lancer l'accès aux entrées.
|
||||
|
||||
N'importe qui peut inviter des amis non adhérents, tant que les contraintes de nombre (un adhérent n'invite pas plus de
|
||||
N'importe qui peut inviter des ami⋅es non adhérent⋅es, tant que les contraintes de nombre (un⋅e adhérent⋅e n'invite pas plus de
|
||||
trois personnes par activité et une personne ne peut pas être invitée plus de 5 fois par an). L'invitation est
|
||||
facturée à l'entrée.
|
||||
|
||||
@ -99,12 +99,12 @@ Entrées aux soirées
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
L'interface d'entrées est simple et ergonomique. Elle contient un champ de texte. À chaque fois que le champ est
|
||||
modifié, un tableau est affiché comprenant la liste des invités et des adhérents dont le prénom, le nom ou un alias
|
||||
modifié, un tableau est affiché comprenant la liste des invité⋅es et des adhérent⋅es dont le prénom, le nom ou un alias
|
||||
de la note est acceptée par le texte entré.
|
||||
|
||||
En cliquant sur la ligne de la personne qui souhaite rentrée, s'il s'agit d'un adhérent, alors la personne est comptée
|
||||
comme entrée à l'activité, sous réserve que sa note soit positive. S'il s'agit d'un invité, alors 3 boutons
|
||||
En cliquant sur la ligne de la personne qui souhaite rentrer, s'il s'agit d'un⋅e adhérent⋅e, alors la personne est comptée
|
||||
comme entrée à l'activité, sous réserve que sa note soit positive. S'il s'agit d'un⋅e invité⋅e, alors 3 boutons
|
||||
apparaîssent, afin de régler la taxe d'invitation : l'un prélève directement depuis la note de l'hôte, les deux autres
|
||||
permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent
|
||||
automatiquement un crédit sur la note de l'hôte, puis une transaction (de type ``GuestTransaction``) est faite depuis
|
||||
la note de l'hôte vers la note de l'organisateur de l'événement.
|
||||
la note de l'hôte vers la note du club organisateur de l'événement.
|
||||
|
@ -1,5 +1,5 @@
|
||||
Applications de la NoteKfet2020
|
||||
===============================
|
||||
Applications de la Note Kfet 2020
|
||||
=================================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
@ -15,27 +15,26 @@ Applications de la NoteKfet2020
|
||||
treasury
|
||||
wei
|
||||
|
||||
La NoteKfet est un projet Django, décomposé en applications.
|
||||
Certaines Applications sont développées uniquement pour ce projet, et sont indispensables,
|
||||
d'autres sont packagesé et sont installées comme dépendances.
|
||||
Enfin des fonctionnalités annexes ont été rajouté, mais ne sont pas essentiel au déploiement de la NoteKfet;
|
||||
leur usage est cependant recommandé.
|
||||
La Note Kfet 2020 est un projet Django, décomposé en applications.
|
||||
Certaines applications sont développées uniquement pour ce projet, et sont indispensables,
|
||||
d'autres sont packagées et sont installées comme dépendances.
|
||||
Enfin, des fonctionnalités annexes ont été rajoutées, mais ne sont pas essentielles au déploiement de la Note Kfet 2020. Leur usage est cependant recommandé.
|
||||
|
||||
Le front utilise le framework Bootstrap4 et quelques morceaux de javascript custom.
|
||||
L'affichage Web utilise le framework Bootstrap4 et quelques morceaux de JavaScript personnalisés.
|
||||
|
||||
Applications indispensables
|
||||
---------------------------
|
||||
|
||||
* ``note_kfet`` :
|
||||
Application "projet" de django, c'est ici que la config de la note est gérée.
|
||||
Application "projet" de django, c'est ici que la configuration de la note est gérée.
|
||||
* `Member <member>`_ :
|
||||
Gestion des profils d'utilisateurs, des clubs et de leur membres.
|
||||
Gestion des profils d'utilisateur⋅rices, des clubs et de leur membres.
|
||||
* `Note <note>`_ :
|
||||
Les notes associés a des utilisateurs ou des clubs.
|
||||
Les notes associées à des utilisateur⋅rices ou des clubs.
|
||||
* `Activity <activity>`_ :
|
||||
La gestion des Activités (créations, gestion, entrée...)
|
||||
La gestion des activités (créations, gestion, entrées,…)
|
||||
* `Permission <permission>`_ :
|
||||
Backend de droits, limites les pouvoirs des utilisateurs
|
||||
Backend de droits, limites les pouvoirs des utilisateur⋅rices
|
||||
* `API <../api>`_ :
|
||||
API REST de la note, est notamment utilisée pour rendre la note dynamique
|
||||
(notamment la page de conso)
|
||||
@ -52,9 +51,9 @@ Applications packagées
|
||||
`<https://django-polymorphic.readthedocs.io/en/stable/>`_
|
||||
|
||||
* ``crispy_forms``
|
||||
Utiliser pour générer des forms avec bootstrap4
|
||||
Utiliser pour générer des formulairess avec Bootstrap4
|
||||
* ``django_tables2``
|
||||
utiliser pour afficher des tables de données et les formater, en python plutôt qu'en HTML.
|
||||
utiliser pour afficher des tables de données et les formater, en Python plutôt qu'en HTML.
|
||||
* ``restframework``
|
||||
Base de l'`API <../api>`_.
|
||||
|
||||
@ -63,11 +62,11 @@ Applications facultatives
|
||||
* `Logs <logs>`_
|
||||
Enregistre toute les modifications effectuées en base de donnée.
|
||||
* ``cas-server``
|
||||
Serveur central d'authenfication, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
|
||||
* `Script <https://gitlab.crans.org/bde/nk20-scripts>`_
|
||||
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc ...
|
||||
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
|
||||
* `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_
|
||||
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc…
|
||||
* `Treasury <treasury>`_ :
|
||||
Interface de gestion pour les trésoriers, émission de facture, remise de chèque, statistiques ...
|
||||
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques…
|
||||
* `WEI <wei>`_ :
|
||||
Interface de gestion du WEI.
|
||||
|
||||
|
@ -6,22 +6,22 @@ Chaque modification effectuée sur un modèle est enregistrée dans la base dans
|
||||
Dès qu'un modèle veut être sauvegardé, deux signaux sont envoyés dans ``logs.signals`` : un avant et un après
|
||||
la sauvegarde.
|
||||
En pré-sauvegarde, on récupère l'ancienne version du modèle, si elle existe.
|
||||
En post-sauvegarde, on récupère l'utilisateur et l'IP courants (voir ci-dessous), on convertit les modèles en JSON
|
||||
En post-sauvegarde, on récupère l'utilisateur⋅rice et l'IP courant⋅es (voir ci-dessous), on convertit les modèles en JSON
|
||||
et on enregistre une entrée ``Changelog`` dans la base de données.
|
||||
|
||||
Pour récupérer l'utilisateur et son IP, le middleware ``logs.middlewares.LogsMiddlewares`` récupère à chaque requête
|
||||
l'utilisateur et l'adresse IP, et les stocke dans le processus courant, afin qu'ils puissent être
|
||||
Pour récupérer l'utilisateur⋅rice et son IP, le middleware ``logs.middlewares.LogsMiddlewares`` récupère à chaque requête
|
||||
l'utilisateur⋅rice et l'adresse IP, et les stocke dans le processus courant, afin qu'ils puissent être
|
||||
récupérés par les signaux.
|
||||
|
||||
Si jamais la modification ne provient pas d'une requête Web, on suppose qu'elle vient d'une instruction
|
||||
lancée avec ``manage.py``.
|
||||
On récupère alors le nom de l'utilisateur dans l'interface de commandes, et si une note est associée à cet alias,
|
||||
On récupère alors le nom de l'utilisateur⋅rice dans l'interface de commandes, et si une note est associée à cet alias,
|
||||
alors on considère que c'est le détenteur de la note qui a effectué cette modification, sur l'adresse IP ``127.0.0.1``.
|
||||
Sinon, le champ est laissé à ``None``.
|
||||
|
||||
Une entrée de ``Changelog`` contient les informations suivantes :
|
||||
|
||||
* Utilisateur (``ForeignKey`` vers ``User``, nullable)
|
||||
* Utilisateur⋅rice (``ForeignKey`` vers ``User``, nullable)
|
||||
* Adresse IP (``GenericIPAddressField``)
|
||||
* Type de modèle enregistré (``ForeignKey`` vers ``Model``)
|
||||
* Identifiant ``pk`` de l'instance enregistrée (``CharField``)
|
||||
@ -54,4 +54,4 @@ Graphe
|
||||
~~~~~~
|
||||
|
||||
.. image:: ../_static/img/graphs/logs.svg
|
||||
:alt: Logs graphe
|
||||
:alt: Logs graph
|
||||
|
@ -1,65 +1,65 @@
|
||||
Application Member
|
||||
==================
|
||||
|
||||
L'application ``member`` s'occcupe de la gestion des utilisateurs enregistrés.
|
||||
L'application ``member`` s'occcupe de la gestion des utilisateur⋅rices enregistré⋅es.
|
||||
|
||||
Le model d'utilisateur ``django.contrib.auth.model.User`` est complété par un ``Profile`` utilisateur.
|
||||
Le model d'utilisateur⋅rice ``django.contrib.auth.model.User`` est complété par un ``Profile`` utilisateur⋅rice.
|
||||
|
||||
Tous les utilisateurs peuvent être membre de ``Club``. Cela se traduit par une adhésion ``Membership``, dont les
|
||||
Toustes les utilisateur⋅rices peuvent être membre de ``Club``. Cela se traduit par une adhésion ``Membership``, dont les
|
||||
caractéristiques sont propres à chaque club.
|
||||
|
||||
En pratique, la NoteKfet possède au minimum deux Club: **Bde** et **Kfet** (instanciés via les fixtures). Et tous
|
||||
les personnes à jour de cotisation sont membre à minima de Bde.
|
||||
Être adhérent du club Kfet permet d'utiliser sa note pour consommer.
|
||||
En pratique, la Note Kfet possède au minimum deux clubs : **Bde** et **Kfet** (instanciés
|
||||
via les fixtures). Et toutes les personnes à jour de cotisation sont membre à minima de
|
||||
BDE. Être adhérent⋅e du club Kfet permet d'utiliser sa note pour consommer.
|
||||
|
||||
Modèles
|
||||
-------
|
||||
|
||||
Utilisateur
|
||||
~~~~~~~~~~~
|
||||
Utilisateur⋅rice
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Le modèle ``User`` est directement implémenté dans Django et n'appartient pas à l'application ``member``, mais il est
|
||||
bon de rappeler à quoi ressemble ce modèle.
|
||||
|
||||
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur a été inscrit (*inutilisé dans la Note*)
|
||||
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur.
|
||||
* ``first_name`` : ``CharField``, prénom de l'utilisateur.
|
||||
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur⋅rice a été inscrit (*inutilisé dans la Note*)
|
||||
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur⋅rice.
|
||||
* ``first_name`` : ``CharField``, prénom de l'utilisateur⋅rice.
|
||||
* ``is_active`` : ``BooleanField``, indique si le compte est actif et peut se connecter.
|
||||
* ``is_staff`` : ``BooleanField``, indique si l'utilisateur peut se connecter à l'interface Django-admin.
|
||||
* ``is_superuser`` : ``BooleanField``, indique si l'utilisateur dispose de droits super-utilisateurs, permettant n'importe quelle action en base de donnée (lecture, ajout, modification, suppression).
|
||||
* ``is_staff`` : ``BooleanField``, indique si l'utilisateur⋅rice peut se connecter à l'interface Django-admin.
|
||||
* ``is_superuser`` : ``BooleanField``, indique si l'utilisateur⋅rice dispose de droits super-utilisateur⋅rices, permettant n'importe quelle action en base de donnée (lecture, ajout, modification, suppression).
|
||||
* ``last_login`` : ``DateTimeField``, date et heure de dernière connexion.
|
||||
* ``last_name`` : ``CharField``, nom de famille de l'utilisateur.
|
||||
* ``password`` : ``CharField``, contient le hash du mot de passe de l'utilisateur. L'algorithme utilisé est celui par défaut de Django : PBKDF2 + HMAC + SHA256 avec 150000 itérations.
|
||||
* ``username`` : ``CharField`` (unique), pseudo de l'utilisateur.
|
||||
* ``last_name`` : ``CharField``, nom de famille de l'utilisateur⋅rice.
|
||||
* ``password`` : ``CharField``, contient le hash du mot de passe de l'utilisateur⋅rice. L'algorithme utilisé est celui par défaut de Django : PBKDF2 + HMAC + SHA256 avec 150000 itérations.
|
||||
* ``username`` : ``CharField`` (unique), pseudo de l'utilisateur⋅rice.
|
||||
|
||||
Profil
|
||||
~~~~~~
|
||||
|
||||
Le modèle ``Profile`` contient un champ ``user`` de type ``OneToOneField``, ce qui permet de voir ce modèle comme une
|
||||
extension du modèle ``User``, sans avoir à le réécrire. Il contient diverses informations personnelles sur
|
||||
l'utilisateur, utiles pour l'adhésion au BDE :
|
||||
l'utilisateur⋅rice, utiles pour l'adhésion au BDE :
|
||||
|
||||
* ``user`` : ``OneToOneField(User)``, utilisateur lié à ce profil
|
||||
* ``address`` : ``CharField``, adresse physique de l'utilisateur
|
||||
* ``paid`` : ``BooleanField``, indique si l'utilisateur normalien est rémunéré ou non (utile pour différencier les montants d'adhésion aux clubs)
|
||||
* ``phone_number`` : ``CharField``, numéro de téléphone de l'utilisateur
|
||||
* ``section`` : ``CharField``, section de l'ENS à laquelle apartient l'utilisateur (exemple : 1A0, ...)
|
||||
* ``user`` : ``OneToOneField(User)``, utilisateur⋅rice lié à ce profil
|
||||
* ``address`` : ``CharField``, adresse physique de l'utilisateur⋅rice
|
||||
* ``paid`` : ``BooleanField``, indique si l'utilisateur⋅rice normalien⋅ne est rémunéré⋅e ou non (utile pour différencier les montants d'adhésion aux clubs)
|
||||
* ``phone_number`` : ``CharField``, numéro de téléphone de l'utilisateur⋅rice
|
||||
* ``section`` : ``CharField``, section de l'ENS à laquelle appartient l'utilisateur⋅rice (exemple : 1A0,…)
|
||||
|
||||
Clubs
|
||||
~~~~~
|
||||
|
||||
La gestion des clubs est une différence majeure avec la Note Kfet 2015. La Note gère ainsi les adhésions des
|
||||
utilisateurs aux différents clubs.
|
||||
utilisateur⋅rices aux différents clubs.
|
||||
|
||||
* ``parent_club`` : ``ForeignKey(Club)``. La présence d'un club parent force l'adhésion au club parent avant de pouvoir adhérer au dit club. Tout club qui n'est pas le club BDE doit avoir le club BDE dans son arborescence.
|
||||
* ``email`` : ``EmailField``, adresse e-mail sur laquelle contacter le bureau du club.
|
||||
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible d'adhérer à un club pour l'année suivante (si adhésions à l'année), en ignorant l'année. Par exemple, l'adhésion BDE est possible à partir du 31/08 par défaut, et c'est à cette date que les adhésions pour l'année future est possible.
|
||||
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible d'adhérer à un club pour l'année suivante (si adhésions à l'année), en ignorant l'année. Par exemple, l'adhésion BDE est possible à partir du 01/08 par défaut, et c'est à cette date que les adhésions pour l'année future est possible.
|
||||
* ``membership_end`` : ``DateField``, date maximale de fin d'adhésion. Pour le club BDE, il s'agit du 30/09 de l'année suivante. Si cette valeur vaut ``null``, la fin d'adhésion n'est pas limitée.
|
||||
* ``membership_duration`` : ``PositiveIntegerField``, durée (en jours) maximale d'adhésion. Par exemple, le club BDE permet des adhésions maximales de 13 mois, soit 396 jours.
|
||||
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un élève normalien (donc rémunéré) puisse adhérer.
|
||||
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un étudiant normalien (donc non rémunéré) puisse adhérer.
|
||||
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e élève normalien⋅ne (donc rémunéré⋅e) puisse adhérer.
|
||||
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e étudiant⋅e normalien⋅ne (donc non rémunéré) puisse adhérer.
|
||||
* ``name`` : ``CharField``, nom du club.
|
||||
* ``require_memberships`` : ``BooleanField``, indique si le club est un vrai club BDE qui nécessite des adhésions de club, ou s'il s'agit d'une note "pot commun" (organisation d'une activité, note de département, ...)
|
||||
* ``require_memberships`` : ``BooleanField``, indique si le club est un vrai club BDE qui nécessite des adhésions de club, ou s'il s'agit d'une note "pot commun" (organisation d'une activité, note de département,…)
|
||||
|
||||
Adhésions
|
||||
~~~~~~~~~
|
||||
@ -67,16 +67,16 @@ Adhésions
|
||||
Comme indiqué précédemment, la note gère les adhésions.
|
||||
|
||||
* ``club`` : ``ForeignKey(Club)``, club lié à l'adhésion.
|
||||
* ``user`` : ``ForeignKey(User)``, utilisateur adhéré.
|
||||
* ``user`` : ``ForeignKey(User)``, utilisateur⋅rice qui a adhéré.
|
||||
* ``date_start`` : ``DateField``, date de début d'adhésion.
|
||||
* ``date_end`` : ``DateField``, date de fin d'adhésion.
|
||||
* ``fee`` : ``PositiveIntegerField``, montant de la cotisation payée.
|
||||
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent.
|
||||
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent⋅e.
|
||||
|
||||
Rôles
|
||||
~~~~~
|
||||
|
||||
Comme indiqué le modèle des adhésions, les adhésions octroient des rôles aux adhérents, qui offrent des permissions
|
||||
Comme indiqué le modèle des adhésions, les adhésions octroient des rôles aux adhérent⋅es, qui offrent des permissions
|
||||
(cf ``RolesPermissions`` dans la page des permissions). Le modèle ``RolesPermissions`` possède un
|
||||
``OneToOneField(Role)``, qui implémente les permissions des rôles. Le modèle ``Role`` à proprement parler ne contient
|
||||
que le champ de son nom (``CharField``).
|
||||
@ -88,7 +88,7 @@ Si le modèle ``MembershipTransaction`` appartient à l'application ``note``, il
|
||||
Le modèle ``MembershipTransaction`` est une extension du modèle ``Transaction`` (application ``note``) qui est de type
|
||||
polymorphique, et contient en plus des informations de base de la transaction un champ ``OneToOneField(Membership)``
|
||||
faisant le lien entre l'adhésion et la transaction liée. Une adhésion club, si elle n'est pas gratuite,
|
||||
génère en effet automatiquement une transaction de l'utilisateur vers le club (voir section adhésions).
|
||||
génère en effet automatiquement une transaction de l'utilisateur⋅rice vers le club (voir section adhésions).
|
||||
|
||||
Graphe
|
||||
------
|
||||
@ -100,28 +100,28 @@ Adhésions
|
||||
---------
|
||||
|
||||
La Note Kfet offre la possibilité aux clubs de gérer l'adhésion de leurs membres. En plus de réguler les cotisations
|
||||
des adhérents, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
|
||||
fonction occupée au sein d'un club (Trésorier de club, président de club, GCKfet, Res[pot], respo info, ...).
|
||||
Une adhésion attribue à un adhérent ses rôles. Les rôles fournissent les permissions. Par exemple, le trésorier d'un
|
||||
des adhérent⋅es, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
|
||||
fonction occupée au sein d'un club (Trésorièr⋅e de club, président⋅e de club, GC Kfet, Res[pot], respo info,…).
|
||||
Une adhésion attribue à un⋅e adhérent⋅e ses rôles. Les rôles fournissent les permissions. Par exemple, læ trésorièr⋅e d'un
|
||||
club a le droit de faire des transferts de et vers la note du club, tant que la source reste au-dessus de -50 €.
|
||||
Une adhésion est considérée comme valide si la date du jour est comprise (au sens large) entre les dates de début et
|
||||
de fin d'adhésion.
|
||||
|
||||
On peut ajouter une adhésion à un utilisateur dans un club à tout non adhérent de ce club. La personne en charge
|
||||
d'adhérer quelqu'un choisit l'utilisateur, les rôles au sein du club et la date de début d'adhésion. Cette date de
|
||||
On peut ajouter une adhésion à un⋅e utilisateur⋅rice dans un club à tout⋅e non adhérent⋅e de ce club. La personne en charge
|
||||
d'adhérer quelqu'un choisit l'utilisateur⋅rice, les rôles au sein du club et la date de début d'adhésion. Cette date de
|
||||
début d'adhésion doit se situer entre les champs ``club.membership_start`` et ``club.membership_end``,
|
||||
si ces champs sont non nuls. Si ``club.parent_club`` n'est pas nul, l'utilisateur doit être membre de ce club.
|
||||
Le montant de la cotisation est fixé en fonction du statut normalien de l'utilisateur (``club.membership_fee_paid``
|
||||
centimes pour les élèves et ``club.membership_fee_unpaid`` centimes pour les étudiants). La date de fin est calculée
|
||||
si ces champs sont non nuls. Si ``club.parent_club`` n'est pas nul, l'utilisateur⋅rice doit être membre de ce club.
|
||||
Le montant de la cotisation est fixé en fonction du statut normalien de l'utilisateur⋅rice (``club.membership_fee_paid``
|
||||
centimes pour les élèves et ``club.membership_fee_unpaid`` centimes pour les étudiant⋅es). La date de fin est calculée
|
||||
comme ce qui suit :
|
||||
|
||||
* Si ``club.membership_duration`` est non nul, alors ``date_end`` = ``date_start`` + ``club.membership_duration``
|
||||
* Sinon ``club``, ``date_end`` = ``date_start`` + 424242 jours (suffisant pour tenir au moins une vie)
|
||||
* Si ``club.membership_end`` est non nul, alors ``date_end`` = min(``date_end``, ``club.membership_end``)
|
||||
|
||||
Si l'utilisateur n'est pas membre du club ``Kfet``, l'adhésion n'est pas possible si le solde disponible sur sa note est
|
||||
Si l'utilisateur⋅rice n'est pas membre du club ``Kfet``, l'adhésion n'est pas possible si le solde disponible sur sa note est
|
||||
insuffisant. Une fois toute ces contraintes vérifiées, l'adhésion est créée. Une transaction de type
|
||||
``MembershipTransaction`` est automatiquement créée de la note de l'utilisateur vers la note du club, finalisant l'adhésion.
|
||||
``MembershipTransaction`` est automatiquement créée de la note de l'utilisateur⋅rice vers la note du club, finalisant l'adhésion.
|
||||
|
||||
Réadhésions
|
||||
~~~~~~~~~~~
|
||||
@ -137,7 +137,7 @@ Il est possible de réadhérer si :
|
||||
* Il n'y a pas encore de réadhésion (pas d'adhésion au même club vérifiant ``new_membership.date_start`` >= ``club.membership_start``)
|
||||
|
||||
Un bouton ``Réadhérer`` apparaît dans la liste des adhésions si le droit est permis et si ces contraintes sont vérifiées.
|
||||
En réadhérant, une nouvelle adhésion est créée pour l'utilisateur avec les mêmes rôles, commençant le lendemain de la
|
||||
En réadhérant, une nouvelle adhésion est créée pour l'utilisateur⋅rice avec les mêmes rôles, commençant le lendemain de la
|
||||
date d'expiration de la précédente adhésion. Si on réadhère le 16 août pour une adhésion finissant le 30 septembre,
|
||||
la nouvelle adhésion commencera le 1er octobre).
|
||||
|
||||
|
@ -7,23 +7,23 @@ Affichage
|
||||
La page de consommations est principalement une communication entre l'`API <../api>`_ et la page en JavaScript.
|
||||
Elle est disponible à l'adresse ``/note/consos/``, et l'onglet n'est visible que pour ceux ayant le droit de voir au
|
||||
moins un bouton. L'affichage, comme tout le reste de la page, est géré avec Boostrap 4.
|
||||
Les boutons que l'utilisateur a le droit de voir sont triés par catégorie.
|
||||
Les boutons que l'utilisateur⋅rice a le droit de voir sont triés par catégorie.
|
||||
|
||||
Sélection des consommations
|
||||
---------------------------
|
||||
|
||||
Lorsque l'utilisateur commence à taper un nom de note, un appel à l'API sur la page ``/api/note/alias`` est fait,
|
||||
récupérant les 20 premiers aliases en accord avec la requête. Quand l'utilisateur survole un alias, un appel à la page
|
||||
Lorsque l'utilisateur⋅rice commence à taper un nom de note, un appel à l'API sur la page ``/api/note/alias`` est fait,
|
||||
récupérant les 20 premiers aliases en accord avec la requête. Quand l'utilisateur⋅rice survole un alias, un appel à la page
|
||||
``/api/note/note/<NOTE_ID>/`` est fait pour récupérer plus d'infos sur la note telles que le solde, le vrai nom de la
|
||||
note et la photo, si toutefois l'utilisateur a le droit de voir ceci.
|
||||
note et la photo, si toutefois l'utilisateur⋅rice a le droit de voir ceci.
|
||||
|
||||
L'utilisateur peut cliquer sur des aliases pour ajouter des émetteurs, et sur des boutons pour ajouter des consommations.
|
||||
Cliquer dans la liste des émetteurs supprime l'élément sélectionné.
|
||||
L'utilisateur⋅rice peut cliquer sur des aliases pour ajouter des émetteur⋅rices, et sur des boutons pour ajouter des consommations.
|
||||
Cliquer dans la liste des émetteur⋅rices supprime l'élément sélectionné.
|
||||
|
||||
Il ya deux possibilités pour faire consommer des adhérents :
|
||||
- En mode **consommation simple** (mode par défaut), les consommations sont débitées dès que émetteurs et consommations
|
||||
Il ya deux possibilités pour faire consommer des adhérent⋅es :
|
||||
- En mode **consommation simple** (mode par défaut), les consommations sont débitées dès que émetteur⋅rices et consommations
|
||||
sont renseignées.
|
||||
- En mode **consommation double**, l'utilisateur doit cliquer sur "Consommer !" pour débiter toutes les consommations.
|
||||
- En mode **consommation double**, l'utilisateur⋅rice doit cliquer sur « **Consommer !** »" pour débiter toutes les consommations.
|
||||
|
||||
Débit des consommations
|
||||
-----------------------
|
||||
@ -71,7 +71,7 @@ des types. Il vaut `42` lors de la rédaction de cette documentation, mais pourr
|
||||
Si une erreur survient lors de la requête (droits insuffisants), un message apparaîtra en haut de page.
|
||||
Dans tous les cas, tous les champs sont réinitialisés.
|
||||
|
||||
L'historique et la balance de l'utilisateur sont ensuite mis à jour via jQuery, qui permet de recharger une partie de page Web.
|
||||
L'historique et le solde de l'utilisateur⋅rice sont ensuite mis à jour via jQuery, qui permet de recharger une partie de page Web.
|
||||
|
||||
Validation/dévalidation des transactions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@ -86,4 +86,4 @@ une requête PATCH est faite à l'API sur l'adresse ``/api/note/transaction/tran
|
||||
"valid": false
|
||||
}
|
||||
|
||||
L'historique et la balance sont ensuite rafraîchis. Si une erreur survient, un message apparaîtra.
|
||||
L'historique et le solde sont ensuite rafraîchis. Si une erreur survient, un message apparaîtra.
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user