mirror of https://gitlab.crans.org/bde/nk20
Merge branch 'beta' into 'main'
Optional scopes + small bug fix See merge request bde/nk20!193
This commit is contained in:
commit
5f69232560
|
@ -1,8 +1,8 @@
|
||||||
# NoteKfet 2020
|
# NoteKfet 2020
|
||||||
|
|
||||||
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt)
|
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
[![pipeline status](https://gitlab.crans.org/bde/nk20/badges/master/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
|
[![pipeline status](https://gitlab.crans.org/bde/nk20/badges/main/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||||
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
|
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/main/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||||
|
|
||||||
## Table des matières
|
## Table des matières
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
prompt: "Password of the database (leave it blank to skip database init)"
|
prompt: "Password of the database (leave it blank to skip database init)"
|
||||||
private: yes
|
private: yes
|
||||||
vars:
|
vars:
|
||||||
mirror: mirror.crans.org
|
mirror: eclats.crans.org
|
||||||
roles:
|
roles:
|
||||||
- 1-apt-basic
|
- 1-apt-basic
|
||||||
- 2-nk20
|
- 2-nk20
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
note:
|
note:
|
||||||
server_name: note.crans.org
|
server_name: note.crans.org
|
||||||
git_branch: master
|
git_branch: main
|
||||||
serve_static: true
|
serve_static: true
|
||||||
cron_enabled: true
|
cron_enabled: true
|
||||||
email: notekfet2020@lists.crans.org
|
email: notekfet2020@lists.crans.org
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
---
|
---
|
||||||
- name: Add buster-backports to apt sources
|
- name: Add buster-backports to apt sources if needed
|
||||||
apt_repository:
|
apt_repository:
|
||||||
repo: deb http://{{ mirror }}/debian buster-backports main
|
repo: deb http://{{ mirror }}/debian buster-backports main
|
||||||
state: present
|
state: present
|
||||||
when: ansible_facts['distribution'] == "Debian"
|
when:
|
||||||
|
- ansible_distribution == "Debian"
|
||||||
|
- ansible_distribution_major_version | int == 10
|
||||||
|
|
||||||
- name: Install note_kfet APT dependencies
|
- name: Install note_kfet APT dependencies
|
||||||
apt:
|
apt:
|
||||||
update_cache: true
|
update_cache: true
|
||||||
default_release: "{{ 'buster-backports' if ansible_facts['distribution'] == 'Debian' }}"
|
|
||||||
install_recommends: false
|
install_recommends: false
|
||||||
name:
|
name:
|
||||||
# Common tools
|
# Common tools
|
||||||
|
|
|
@ -7,8 +7,11 @@ from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from member.api.serializers import ProfileSerializer, MembershipSerializer
|
from member.api.serializers import ProfileSerializer, MembershipSerializer
|
||||||
|
from member.models import Membership
|
||||||
from note.api.serializers import NoteSerializer
|
from note.api.serializers import NoteSerializer
|
||||||
from note.models import Alias
|
from note.models import Alias
|
||||||
|
from note_kfet.middlewares import get_current_request
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
@ -45,18 +48,30 @@ class OAuthSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
normalized_name = serializers.SerializerMethodField()
|
normalized_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
profile = ProfileSerializer()
|
profile = serializers.SerializerMethodField()
|
||||||
|
|
||||||
note = NoteSerializer()
|
note = serializers.SerializerMethodField()
|
||||||
|
|
||||||
memberships = serializers.SerializerMethodField()
|
memberships = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_normalized_name(self, obj):
|
def get_normalized_name(self, obj):
|
||||||
return Alias.normalize(obj.username)
|
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.has_perm(get_current_request(), obj.profile, 'view') 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.has_perm(get_current_request(), obj.note, 'view') else None
|
||||||
|
|
||||||
def get_memberships(self, obj):
|
def get_memberships(self, obj):
|
||||||
|
# Display only memberships that we are allowed to see.
|
||||||
return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
|
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:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
|
|
@ -264,8 +264,10 @@ class Club(models.Model):
|
||||||
today = datetime.date.today()
|
today = datetime.date.today()
|
||||||
|
|
||||||
if (today - self.membership_start).days >= 365:
|
if (today - self.membership_start).days >= 365:
|
||||||
|
if self.membership_start:
|
||||||
self.membership_start = datetime.date(self.membership_start.year + 1,
|
self.membership_start = datetime.date(self.membership_start.year + 1,
|
||||||
self.membership_start.month, self.membership_start.day)
|
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 = datetime.date(self.membership_end.year + 1,
|
||||||
self.membership_end.month, self.membership_end.day)
|
self.membership_end.month, self.membership_end.day)
|
||||||
self._force_save = True
|
self._force_save = True
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||||
from oauth2_provider.scopes import BaseScopes
|
from oauth2_provider.scopes import BaseScopes
|
||||||
from member.models import Club
|
from member.models import Club
|
||||||
from note_kfet.middlewares import get_current_request
|
from note_kfet.middlewares import get_current_request
|
||||||
|
@ -32,3 +32,26 @@ class PermissionScopes(BaseScopes):
|
||||||
return []
|
return []
|
||||||
return [f"{p.id}_{p.membership.club.id}"
|
return [f"{p.id}_{p.membership.club.id}"
|
||||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
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
|
||||||
|
|
|
@ -11,25 +11,25 @@
|
||||||
<div class="accordion" id="accordionApps">
|
<div class="accordion" id="accordionApps">
|
||||||
{% for app, app_scopes in scopes.items %}
|
{% for app, app_scopes in scopes.items %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header" id="app-{{ app.name.lower }}-title">
|
<div class="card-header" id="app-{{ app.name|slugify }}-title">
|
||||||
<a class="text-decoration-none collapsed" href="#" data-toggle="collapse"
|
<a class="text-decoration-none collapsed" href="#" data-toggle="collapse"
|
||||||
data-target="#app-{{ app.name.lower }}" aria-expanded="false"
|
data-target="#app-{{ app.name|slugify }}" aria-expanded="false"
|
||||||
aria-controls="app-{{ app.name.lower }}">
|
aria-controls="app-{{ app.name|slugify }}">
|
||||||
{{ app.name }}
|
{{ app.name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse" id="app-{{ app.name.lower }}" aria-labelledby="app-{{ app.name.lower }}" data-target="#accordionApps">
|
<div class="collapse" id="app-{{ app.name|slugify }}" aria-labelledby="app-{{ app.name|slugify }}" data-target="#accordionApps">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% for scope_id, scope_desc in app_scopes.items %}
|
{% for scope_id, scope_desc in app_scopes.items %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-check-label" for="scope-{{ app.name.lower }}-{{ scope_id }}">
|
<label class="form-check-label" for="scope-{{ app.name|slugify }}-{{ scope_id }}">
|
||||||
<input type="checkbox" id="scope-{{ app.name.lower }}-{{ scope_id }}"
|
<input type="checkbox" id="scope-{{ app.name|slugify }}-{{ scope_id }}"
|
||||||
name="scope-{{ app.name.lower }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
|
name="scope-{{ app.name|slugify }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
|
||||||
{{ scope_desc }}
|
{{ scope_desc }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<p id="url-{{ app.name.lower }}">
|
<p id="url-{{ app.name|slugify }}">
|
||||||
<a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code" target="_blank">
|
<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
|
{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code
|
||||||
</a>
|
</a>
|
||||||
|
@ -51,11 +51,10 @@
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
<script>
|
<script>
|
||||||
{% for app in scopes.keys %}
|
{% for app in scopes.keys %}
|
||||||
let elements = document.getElementsByName("scope-{{ app.name.lower }}");
|
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
|
||||||
for (let element of elements) {
|
|
||||||
element.onchange = function (event) {
|
element.onchange = function (event) {
|
||||||
let scope = ""
|
let scope = ""
|
||||||
for (let element of elements) {
|
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
|
||||||
if (element.checked) {
|
if (element.checked) {
|
||||||
scope += element.value + " "
|
scope += element.value + " "
|
||||||
}
|
}
|
||||||
|
@ -63,7 +62,7 @@
|
||||||
|
|
||||||
scope = scope.substr(0, scope.length - 1)
|
scope = scope.substr(0, scope.length - 1)
|
||||||
|
|
||||||
document.getElementById("url-{{ app.name.lower }}").innerHTML = 'Scopes : ' + scope
|
document.getElementById("url-{{ app.name|slugify }}").innerHTML = 'Scopes : ' + scope
|
||||||
+ '<br><a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='+ scope.replaceAll(' ', '%20')
|
+ '<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='
|
+ '" target="_blank">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='
|
||||||
+ scope.replaceAll(' ', '%20') + '</a>'
|
+ scope.replaceAll(' ', '%20') + '</a>'
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
|
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
|
||||||
from .wei2021 import WEISurvey2021
|
from .wei2022 import WEISurvey2022
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||||
]
|
]
|
||||||
|
|
||||||
CurrentSurvey = WEISurvey2021
|
CurrentSurvey = WEISurvey2022
|
||||||
|
|
|
@ -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()
|
|
@ -25,6 +25,7 @@ class TestWEIAlgorithm(TestCase):
|
||||||
email="wei2021@example.com",
|
email="wei2021@example.com",
|
||||||
date_start='2021-09-17',
|
date_start='2021-09-17',
|
||||||
date_end='2021-09-19',
|
date_end='2021-09-19',
|
||||||
|
year=2021,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.buses = []
|
self.buses = []
|
||||||
|
|
|
@ -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 %
|
|
@ -782,7 +782,7 @@ class TestDefaultWEISurvey(TestCase):
|
||||||
WEISurvey.update_form(None, None)
|
WEISurvey.update_form(None, None)
|
||||||
|
|
||||||
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
|
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
|
||||||
self.assertEqual(CurrentSurvey.get_year(), 2021)
|
self.assertEqual(CurrentSurvey.get_year(), 2022)
|
||||||
|
|
||||||
|
|
||||||
class TestWeiAPI(TestAPI):
|
class TestWeiAPI(TestAPI):
|
||||||
|
|
|
@ -86,7 +86,7 @@ Génération
|
||||||
|
|
||||||
Les factures peuvent s'exporter au format PDF (là est tout leur intérêt). Pour cela, on utilise le template LaTeX
|
Les factures peuvent s'exporter au format PDF (là est tout leur intérêt). Pour cela, on utilise le template LaTeX
|
||||||
présent à l'adresse suivante :
|
présent à l'adresse suivante :
|
||||||
`/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/master/templates/treasury/invoice_sample.tex>`_
|
`/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/main/templates/treasury/invoice_sample.tex>`_
|
||||||
|
|
||||||
On le remplit avec les données de la facture et les données du BDE, hard-codées. On copie le template rempli dans un
|
On le remplit avec les données de la facture et les données du BDE, hard-codées. On copie le template rempli dans un
|
||||||
ficher tex dans un dossier temporaire. On fait ensuite 2 appels à ``pdflatex`` pour générer la facture au format PDF.
|
ficher tex dans un dossier temporaire. On fait ensuite 2 appels à ``pdflatex`` pour générer la facture au format PDF.
|
||||||
|
|
|
@ -41,8 +41,14 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions
|
||||||
|
|
||||||
OAUTH2_PROVIDER = {
|
OAUTH2_PROVIDER = {
|
||||||
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
||||||
|
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
|
||||||
|
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
|
||||||
|
et de demander des scopes facultatives (voir plus bas).
|
||||||
|
Un jeton de rafraîchissement expire de plus au bout de 14 jours, si non-renouvelé.
|
||||||
|
|
||||||
On ajoute enfin les routes dans ``urls.py`` :
|
On ajoute enfin les routes dans ``urls.py`` :
|
||||||
|
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
@ -94,6 +100,27 @@ du format renvoyé.
|
||||||
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
|
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
|
||||||
jetons.
|
jetons.
|
||||||
|
|
||||||
|
.. danger::
|
||||||
|
|
||||||
|
Demander des scopes n'implique pas de les avoir.
|
||||||
|
|
||||||
|
Lorsque des scopes sont demandées par un client, la Note
|
||||||
|
va considérer l'ensemble des permissions accessibles parmi
|
||||||
|
ce qui est demandé. Dans vos programmes, vous devrez donc
|
||||||
|
vérifier les permissions acquises (communiquées lors de la
|
||||||
|
récupération du jeton d'accès à partir du grant code),
|
||||||
|
et prévoir un comportement dans le cas où des permissions
|
||||||
|
sont manquantes.
|
||||||
|
|
||||||
|
Cela offre un intérêt supérieur par rapport au protocole
|
||||||
|
OAuth2 classique, consistant à demander trop de permissions
|
||||||
|
et agir en conséquence.
|
||||||
|
|
||||||
|
Par exemple, vous pourriez demander la permission d'accéder
|
||||||
|
aux membres d'un club ou de faire des transactions, et agir
|
||||||
|
uniquement dans le cas où l'utilisateur connecté possède la
|
||||||
|
permission problématique.
|
||||||
|
|
||||||
Avec Django-allauth
|
Avec Django-allauth
|
||||||
###################
|
###################
|
||||||
|
|
||||||
|
@ -116,6 +143,7 @@ installées (sur votre propre client), puis de bien ajouter l'application social
|
||||||
SOCIALACCOUNT_PROVIDERS = {
|
SOCIALACCOUNT_PROVIDERS = {
|
||||||
'notekfet': {
|
'notekfet': {
|
||||||
# 'DOMAIN': 'note.crans.org',
|
# 'DOMAIN': 'note.crans.org',
|
||||||
|
'SCOPE': ['1_1', '2_1'],
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
@ -123,6 +151,10 @@ installées (sur votre propre client), puis de bien ajouter l'application social
|
||||||
Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il
|
Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il
|
||||||
se connectera à ``note.crans.org`` si vous ne renseignez rien.
|
se connectera à ``note.crans.org`` si vous ne renseignez rien.
|
||||||
|
|
||||||
|
Le paramètre ``SCOPE`` permet de définir les scopes à demander.
|
||||||
|
Dans l'exemple ci-dessous, les permissions d'accéder à l'utilisateur
|
||||||
|
et au profil sont demandées.
|
||||||
|
|
||||||
En créant l'application sur la note, vous pouvez renseigner
|
En créant l'application sur la note, vous pouvez renseigner
|
||||||
``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection,
|
``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection,
|
||||||
à adapter selon votre configuration.
|
à adapter selon votre configuration.
|
||||||
|
|
|
@ -88,7 +88,7 @@ On clone donc le dépôt en tant que ``www-data`` :
|
||||||
|
|
||||||
$ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git /var/www/note_kfet
|
$ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git /var/www/note_kfet
|
||||||
|
|
||||||
Par défaut, le dépôt est configuré pour suivre la branche ``master``, qui est la branche
|
Par défaut, le dépôt est configuré pour suivre la branche ``main``, qui est la branche
|
||||||
stable, notamment installée sur `<https://note.crans.org/>`_. Pour changer de branche,
|
stable, notamment installée sur `<https://note.crans.org/>`_. Pour changer de branche,
|
||||||
notamment passer sur la branche ``beta`` sur un serveur de pré-production (un peu comme
|
notamment passer sur la branche ``beta`` sur un serveur de pré-production (un peu comme
|
||||||
`<https://note-dev.crans.org/>`_), on peut faire :
|
`<https://note-dev.crans.org/>`_), on peut faire :
|
||||||
|
@ -587,7 +587,7 @@ Dans ce fichier, remplissez :
|
||||||
---
|
---
|
||||||
note:
|
note:
|
||||||
server_name: note.crans.org
|
server_name: note.crans.org
|
||||||
git_branch: master
|
git_branch: main
|
||||||
cron_enabled: true
|
cron_enabled: true
|
||||||
email: notekfet2020@lists.crans.org
|
email: notekfet2020@lists.crans.org
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
|
@ -248,6 +250,8 @@ REST_FRAMEWORK = {
|
||||||
# OAuth2 Provider
|
# OAuth2 Provider
|
||||||
OAUTH2_PROVIDER = {
|
OAUTH2_PROVIDER = {
|
||||||
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
||||||
|
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
|
||||||
|
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Take control on how widget templates are sourced
|
# Take control on how widget templates are sourced
|
||||||
|
|
Loading…
Reference in New Issue