mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-06-21 18:08:21 +02:00
Compare commits
115 Commits
d595d908c6
...
non-BDE-me
Author | SHA1 | Date | |
---|---|---|---|
5d16dc4e7d | |||
131f508433 | |||
c1a353963a | |||
178ce2b579 | |||
9162319734 | |||
5d2a8e9b79 | |||
33c94d0720 | |||
5040e8e8ea | |||
c5697c4cb4 | |||
e188c5a153 | |||
94e1fdc93a | |||
d1ef367bab | |||
0fbb19c5fd | |||
21cbf2b21a | |||
185a2cabf2 | |||
7552e55c8d | |||
361de9f8b4 | |||
e2426bd6a6 | |||
7fea619a9f | |||
7b5eefcc0a | |||
e4aa16986f | |||
b92e6e4e10 | |||
dd675b3676 | |||
f50849b4f8 | |||
73ff35c232 | |||
a5df98224f | |||
2cb9ac8735 | |||
35d4849a28 | |||
96539d262f | |||
946674f59b | |||
a201d8376a | |||
a21b9275ea | |||
d4e85e8215 | |||
7af2ebba40 | |||
bd94400883 | |||
5558341c8c | |||
35ef82223c | |||
9ccac36831 | |||
2e71ce05a9 | |||
f2cb10b69f | |||
24c4edf2e3 | |||
213e9a8b12 | |||
2c56178b15 | |||
48a5b04579 | |||
2ab5c4082a | |||
053225c6dc | |||
ac7b86651d | |||
21f5a5d566 | |||
ff9c78ed4e | |||
1e121297d1 | |||
549f56dc0b | |||
debeb33d46 | |||
6d7076b03e | |||
196df1e775 | |||
28117c8c61 | |||
0d9891fbd8 | |||
4be4a18dd1 | |||
27b00ba4f0 | |||
3fcbb4f310 | |||
d1c9a2a7f1 | |||
a673fd6871 | |||
a324d3a892 | |||
951ba74f8f | |||
abc4f14bd1 | |||
47138bafd4 | |||
a3920fcae3 | |||
ae4213d087 | |||
b2b1f03b46 | |||
1c5ed2bd3f | |||
a7e87ea639 | |||
cbf92651f0 | |||
12c93ff9da | |||
354c79bb82 | |||
1ea7b3dda1 | |||
35ffbfcf55 | |||
162371042c | |||
581715d804 | |||
c7c6f0350f | |||
9d1024024b | |||
6f67d2c629 | |||
4b97ab2e2a | |||
dcfd0167e7 | |||
50a680eed2 | |||
226a2a6357 | |||
48462f2ffc | |||
260513ae3b | |||
210a3cc93c | |||
896095a44c | |||
3f997f94fa | |||
0801ad64ae | |||
64bd5ed546 | |||
4c390dce17 | |||
adacc293f5 | |||
968fa64d37 | |||
a481adbae4 | |||
4de2e987ef | |||
9e6342c929 | |||
74de358953 | |||
7322d55789 | |||
1a258dfe9e | |||
bbbdcc7247 | |||
feeb99041f | |||
6c61daf1c5 | |||
96215cc1ff | |||
b7a71d911d | |||
2ee7f41dfe | |||
fb3337966e | |||
399a32bece | |||
82fea65b5e | |||
abc88d0118 | |||
b6b81a8b8f | |||
d228dbf225 | |||
516a7f4be5 | |||
2f8c9b54e7 | |||
e9f18c3ed9 |
@ -7,25 +7,10 @@ stages:
|
|||||||
variables:
|
variables:
|
||||||
GIT_SUBMODULE_STRATEGY: recursive
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
|
||||||
# Debian Buster
|
# Ubuntu 22.04
|
||||||
# py37-django22:
|
py310-django42:
|
||||||
# stage: test
|
|
||||||
# image: debian:buster-backports
|
|
||||||
# before_script:
|
|
||||||
# - >
|
|
||||||
# apt-get update &&
|
|
||||||
# apt-get install --no-install-recommends -t buster-backports -y
|
|
||||||
# python3-django python3-django-crispy-forms
|
|
||||||
# python3-django-extensions python3-django-filters python3-django-polymorphic
|
|
||||||
# python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
|
||||||
# python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
|
||||||
# python3-bs4 python3-setuptools tox texlive-xetex
|
|
||||||
# script: tox -e py37-django22
|
|
||||||
|
|
||||||
# Ubuntu 20.04
|
|
||||||
py38-django22:
|
|
||||||
stage: test
|
stage: test
|
||||||
image: ubuntu:20.04
|
image: ubuntu:22.04
|
||||||
before_script:
|
before_script:
|
||||||
# Fix tzdata prompt
|
# Fix tzdata prompt
|
||||||
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||||
@ -37,12 +22,12 @@ py38-django22:
|
|||||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||||
python3-bs4 python3-setuptools tox texlive-xetex
|
python3-bs4 python3-setuptools tox texlive-xetex
|
||||||
script: tox -e py38-django22
|
script: tox -e py310-django42
|
||||||
|
|
||||||
# Debian Bullseye
|
# Debian Bookworm
|
||||||
py39-django22:
|
py311-django42:
|
||||||
stage: test
|
stage: test
|
||||||
image: debian:bullseye
|
image: debian:bookworm
|
||||||
before_script:
|
before_script:
|
||||||
- >
|
- >
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
@ -52,11 +37,11 @@ py39-django22:
|
|||||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||||
python3-bs4 python3-setuptools tox texlive-xetex
|
python3-bs4 python3-setuptools tox texlive-xetex
|
||||||
script: tox -e py39-django22
|
script: tox -e py311-django42
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
stage: quality-assurance
|
stage: quality-assurance
|
||||||
image: debian:bullseye
|
image: debian:bookworm
|
||||||
before_script:
|
before_script:
|
||||||
- apt-get update && apt-get install -y tox
|
- apt-get update && apt-get install -y tox
|
||||||
script: tox -e linters
|
script: tox -e linters
|
||||||
|
@ -5,7 +5,7 @@ from django.contrib import admin
|
|||||||
from note_kfet.admin import admin_site
|
from note_kfet.admin import admin_site
|
||||||
|
|
||||||
from .forms import GuestForm
|
from .forms import GuestForm
|
||||||
from .models import Activity, ActivityType, Entry, Guest
|
from .models import Activity, ActivityType, Entry, Guest, Opener
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Activity, site=admin_site)
|
@admin.register(Activity, site=admin_site)
|
||||||
@ -45,3 +45,11 @@ class EntryAdmin(admin.ModelAdmin):
|
|||||||
Admin customisation for Entry
|
Admin customisation for Entry
|
||||||
"""
|
"""
|
||||||
list_display = ('note', 'activity', 'time', 'guest')
|
list_display = ('note', 'activity', 'time', 'guest')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Opener, site=admin_site)
|
||||||
|
class OpenerAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin customisation for Opener
|
||||||
|
"""
|
||||||
|
list_display = ('activity', 'opener')
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.validators import UniqueTogetherValidator
|
||||||
|
|
||||||
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction
|
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
|
||||||
|
|
||||||
|
|
||||||
class ActivityTypeSerializer(serializers.ModelSerializer):
|
class ActivityTypeSerializer(serializers.ModelSerializer):
|
||||||
@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = GuestTransaction
|
model = GuestTransaction
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class OpenerSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Openers.
|
||||||
|
The djangorestframework plugin will analyse the model `Opener` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Opener
|
||||||
|
fields = '__all__'
|
||||||
|
validators = [UniqueTogetherValidator(
|
||||||
|
queryset=Opener.objects.all(), fields=("opener", "activity"),
|
||||||
|
message=_("This opener already exists"))]
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
|
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet, OpenerViewSet
|
||||||
|
|
||||||
|
|
||||||
def register_activity_urls(router, path):
|
def register_activity_urls(router, path):
|
||||||
@ -12,3 +12,4 @@ def register_activity_urls(router, path):
|
|||||||
router.register(path + '/type', ActivityTypeViewSet)
|
router.register(path + '/type', ActivityTypeViewSet)
|
||||||
router.register(path + '/guest', GuestViewSet)
|
router.register(path + '/guest', GuestViewSet)
|
||||||
router.register(path + '/entry', EntryViewSet)
|
router.register(path + '/entry', EntryViewSet)
|
||||||
|
router.register(path + '/opener', OpenerViewSet)
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from api.filters import RegexSafeSearchFilter
|
||||||
from api.viewsets import ReadProtectedModelViewSet
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer
|
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
|
||||||
from ..models import Activity, ActivityType, Entry, Guest
|
from ..models import Activity, ActivityType, Entry, Guest, Opener
|
||||||
|
|
||||||
|
|
||||||
class ActivityTypeViewSet(ReadProtectedModelViewSet):
|
class ActivityTypeViewSet(ReadProtectedModelViewSet):
|
||||||
@ -29,7 +32,7 @@ class ActivityViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Activity.objects.order_by('id')
|
queryset = Activity.objects.order_by('id')
|
||||||
serializer_class = ActivitySerializer
|
serializer_class = ActivitySerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
|
filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
|
||||||
'date_start', 'date_end', 'valid', 'open', ]
|
'date_start', 'date_end', 'valid', 'open', ]
|
||||||
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
|
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
|
||||||
@ -47,7 +50,7 @@ class GuestViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Guest.objects.order_by('id')
|
queryset = Guest.objects.order_by('id')
|
||||||
serializer_class = GuestSerializer
|
serializer_class = GuestSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
|
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
|
||||||
'inviter__alias__normalized_name', ]
|
'inviter__alias__normalized_name', ]
|
||||||
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
|
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
|
||||||
@ -62,7 +65,36 @@ class EntryViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Entry.objects.order_by('id')
|
queryset = Entry.objects.order_by('id')
|
||||||
serializer_class = EntrySerializer
|
serializer_class = EntrySerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['activity', 'time', 'note', 'guest', ]
|
filterset_fields = ['activity', 'time', 'note', 'guest', ]
|
||||||
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
|
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
|
||||||
'$guest__last_name', '$guest__first_name', ]
|
'$guest__last_name', '$guest__first_name', ]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenerViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST Opener View set.
|
||||||
|
The djangorestframework plugin will get all `Opener` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/activity/opener/
|
||||||
|
"""
|
||||||
|
queryset = Opener.objects
|
||||||
|
serializer_class = OpenerSerializer
|
||||||
|
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend]
|
||||||
|
search_fields = ['$opener__alias__name', '$opener__alias__normalized_name',
|
||||||
|
'$activity__name']
|
||||||
|
filterset_fields = ['opener', 'opener__noteuser__user', 'activity']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
serializer_class = self.serializer_class
|
||||||
|
if self.request.method in ['PUT', 'PATCH']:
|
||||||
|
# opener-activity can't change
|
||||||
|
serializer_class.Meta.read_only_fields = ('opener', 'acitivity',)
|
||||||
|
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)
|
||||||
|
@ -4,13 +4,14 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from random import shuffle
|
from random import shuffle
|
||||||
|
|
||||||
|
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from member.models import Club
|
from member.models import Club
|
||||||
from note.models import Note, NoteUser
|
from note.models import Note, NoteUser
|
||||||
from note_kfet.inputs import Autocomplete, DateTimePickerInput
|
from note_kfet.inputs import Autocomplete
|
||||||
from note_kfet.middlewares import get_current_request
|
from note_kfet.middlewares import get_current_request
|
||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
@ -43,7 +44,7 @@ class ActivityForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Activity
|
model = Activity
|
||||||
exclude = ('creater', 'valid', 'open', )
|
exclude = ('creater', 'valid', 'open', 'opener', )
|
||||||
widgets = {
|
widgets = {
|
||||||
"organizer": Autocomplete(
|
"organizer": Autocomplete(
|
||||||
model=Club,
|
model=Club,
|
||||||
|
28
apps/activity/migrations/0004_opener.py
Normal file
28
apps/activity/migrations/0004_opener.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-08-01 12:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('note', '0006_trust'),
|
||||||
|
('activity', '0003_auto_20240323_1422'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Opener',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opener', to='activity.Activity', verbose_name='activity')),
|
||||||
|
('opener', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.Note', verbose_name='opener')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'opener',
|
||||||
|
'verbose_name_plural': 'openers',
|
||||||
|
'unique_together': {('opener', 'activity')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-08-28 08:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('note', '0006_trust'),
|
||||||
|
('activity', '0004_opener'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='opener',
|
||||||
|
options={'verbose_name': 'Opener', 'verbose_name_plural': 'Openers'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='opener',
|
||||||
|
name='opener',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.note', verbose_name='Opener'),
|
||||||
|
),
|
||||||
|
]
|
@ -11,7 +11,7 @@ from django.db import models, transaction
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from note.models import NoteUser, Transaction
|
from note.models import NoteUser, Transaction, Note
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
@ -310,3 +310,31 @@ class GuestTransaction(Transaction):
|
|||||||
@property
|
@property
|
||||||
def type(self):
|
def type(self):
|
||||||
return _('Invitation')
|
return _('Invitation')
|
||||||
|
|
||||||
|
|
||||||
|
class Opener(models.Model):
|
||||||
|
"""
|
||||||
|
Allow the user to make activity entries without more rights
|
||||||
|
"""
|
||||||
|
activity = models.ForeignKey(
|
||||||
|
Activity,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='opener',
|
||||||
|
verbose_name=_('activity')
|
||||||
|
)
|
||||||
|
|
||||||
|
opener = models.ForeignKey(
|
||||||
|
Note,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='activity_responsible',
|
||||||
|
verbose_name=_('Opener')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Opener")
|
||||||
|
verbose_name_plural = _("Openers")
|
||||||
|
unique_together = ("opener", "activity")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return _("{opener} is opener of activity {acivity}").format(
|
||||||
|
opener=str(self.opener), acivity=str(self.activity))
|
||||||
|
57
apps/activity/static/activity/js/opener.js
Normal file
57
apps/activity/static/activity/js/opener.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* On form submit, add a new opener
|
||||||
|
*/
|
||||||
|
function form_create_opener (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('opener') + '/',
|
||||||
|
function (opener_alias) {
|
||||||
|
create_opener(formData.get('activity'), opener_alias.note)
|
||||||
|
}).fail(function (xhr, _textStatus, _error) {
|
||||||
|
errMsg(xhr.responseJSON)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an opener between an activity and a user
|
||||||
|
* @param activity:Integer activity id
|
||||||
|
* @param opener:Integer user note id
|
||||||
|
*/
|
||||||
|
function create_opener(activity, opener) {
|
||||||
|
$.post('/api/activity/opener/', {
|
||||||
|
activity: activity,
|
||||||
|
opener: opener,
|
||||||
|
csrfmiddlewaretoken: CSRF_TOKEN
|
||||||
|
}).done(function () {
|
||||||
|
// Reload tables
|
||||||
|
$('#opener_table').load(location.pathname + ' #opener_table')
|
||||||
|
addMsg(gettext('Opener successfully added'), 'success')
|
||||||
|
}).fail(function (xhr, _textStatus, _error) {
|
||||||
|
errMsg(xhr.responseJSON)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On click of "delete", delete the opener
|
||||||
|
* @param button_id:Integer Opener id to remove
|
||||||
|
*/
|
||||||
|
function delete_button (button_id) {
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/activity/opener/' + button_id + '/',
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
|
||||||
|
}).done(function () {
|
||||||
|
addMsg(gettext('Opener successfully deleted'), 'success')
|
||||||
|
$('#opener_table').load(location.pathname + ' #opener_table')
|
||||||
|
}).fail(function (xhr, _textStatus, _error) {
|
||||||
|
errMsg(xhr.responseJSON)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
// Attach event
|
||||||
|
document.getElementById('form_opener').addEventListener('submit', form_create_opener)
|
||||||
|
})
|
@ -5,11 +5,13 @@ from django.utils import timezone
|
|||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from note_kfet.middlewares import get_current_request
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2 import A
|
from django_tables2 import A
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
from note.templatetags.pretty_money import pretty_money
|
from note.templatetags.pretty_money import pretty_money
|
||||||
|
|
||||||
from .models import Activity, Entry, Guest
|
from .models import Activity, Entry, Guest, Opener
|
||||||
|
|
||||||
|
|
||||||
class ActivityTable(tables.Table):
|
class ActivityTable(tables.Table):
|
||||||
@ -113,3 +115,34 @@ class EntryTable(tables.Table):
|
|||||||
'data-last-name': lambda record: record.last_name,
|
'data-last-name': lambda record: record.last_name,
|
||||||
'data-first-name': lambda record: record.first_name,
|
'data-first-name': lambda record: record.first_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# function delete_button(id) provided in template file
|
||||||
|
DELETE_TEMPLATE = """
|
||||||
|
<button id="{{ record.pk }}" class="btn btn-danger btn-sm" onclick="delete_button(this.id)"> {{ delete_trans }}</button>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenerTable(tables.Table):
|
||||||
|
class Meta:
|
||||||
|
attrs = {
|
||||||
|
'class': 'table table condensed table-striped',
|
||||||
|
'id': "opener_table"
|
||||||
|
}
|
||||||
|
model = Opener
|
||||||
|
fields = ("opener",)
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
|
||||||
|
show_header = False
|
||||||
|
opener = 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(), "activity.delete_opener", record)
|
||||||
|
else '')}},
|
||||||
|
verbose_name=_("Delete"),)
|
||||||
|
@ -4,11 +4,31 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% load i18n perms %}
|
{% load i18n perms %}
|
||||||
{% load render_table from django_tables2 %}
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load static django_tables2 i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="text-white">{{ title }}</h1>
|
<h1 class="text-white">{{ title }}</h1>
|
||||||
{% include "activity/includes/activity_info.html" %}
|
{% include "activity/includes/activity_info.html" %}
|
||||||
|
|
||||||
|
{% if activity.activity_type.manage_entries and ".change__opener"|has_perm:activity %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "Openers" %}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<form class="input-group" method="POST" id="form_opener">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="activity" value="{{ object.pk }}">
|
||||||
|
{%include "autocomplete_model.html" %}
|
||||||
|
<div class="input-group-append">
|
||||||
|
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% render_table opener %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if guests.data %}
|
{% if guests.data %}
|
||||||
<div class="card bg-white mb-3">
|
<div class="card bg-white mb-3">
|
||||||
<h3 class="card-header text-center">
|
<h3 class="card-header text-center">
|
||||||
@ -22,6 +42,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
|
<script src="{% static "activity/js/opener.js" %}"></script>
|
||||||
|
<script src="{% static "js/autocomplete_model.js" %}"></script>
|
||||||
<script>
|
<script>
|
||||||
function remove_guest(guest_id) {
|
function remove_guest(guest_id) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
|
@ -23,19 +23,19 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<script>
|
<script>
|
||||||
var date_end = document.getElementById("id_date_end");
|
var date_end = document.getElementById("id_date_end");
|
||||||
var date_start = document.getElementById("id_date_start");
|
var date_start = document.getElementById("id_date_start");
|
||||||
|
|
||||||
function update_date_end (){
|
function update_date_end (){
|
||||||
if(date_end.value=="" || date_end.value<date_start.value){
|
if(date_end.value=="" || date_end.value<date_start.value){
|
||||||
date_end.value = date_start.value;
|
date_end.value = date_start.value;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function update_date_start (){
|
function update_date_start (){
|
||||||
if(date_start.value=="" || date_end.value<date_start.value){
|
if(date_start.value=="" || date_end.value<date_start.value){
|
||||||
date_start.value = date_end.value;
|
date_start.value = date_end.value;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
date_start.addEventListener('focusout', update_date_end);
|
date_start.addEventListener('focusout', update_date_end);
|
||||||
date_end.addEventListener('focusout', update_date_start);
|
date_end.addEventListener('focusout', update_date_start);
|
||||||
|
|
||||||
|
@ -18,14 +18,15 @@ from django.views import View
|
|||||||
from django.views.decorators.cache import cache_page
|
from django.views.decorators.cache import cache_page
|
||||||
from django.views.generic import DetailView, TemplateView, UpdateView
|
from django.views.generic import DetailView, TemplateView, UpdateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from django_tables2.views import MultiTableMixin
|
from django_tables2.views import MultiTableMixin, SingleTableMixin
|
||||||
|
from api.viewsets import is_regex
|
||||||
from note.models import Alias, NoteSpecial, NoteUser
|
from note.models import Alias, NoteSpecial, NoteUser
|
||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||||
|
|
||||||
from .forms import ActivityForm, GuestForm
|
from .forms import ActivityForm, GuestForm
|
||||||
from .models import Activity, Entry, Guest
|
from .models import Activity, Entry, Guest, Opener
|
||||||
from .tables import ActivityTable, EntryTable, GuestTable
|
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
|
||||||
|
|
||||||
|
|
||||||
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
@ -63,19 +64,15 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
|
|||||||
Displays all Activities, and classify if they are on-going or upcoming ones.
|
Displays all Activities, and classify if they are on-going or upcoming ones.
|
||||||
"""
|
"""
|
||||||
model = Activity
|
model = Activity
|
||||||
tables = [ActivityTable, ActivityTable]
|
tables = [
|
||||||
|
lambda data: ActivityTable(data, prefix="all-"),
|
||||||
|
lambda data: ActivityTable(data, prefix="upcoming-"),
|
||||||
|
]
|
||||||
extra_context = {"title": _("Activities")}
|
extra_context = {"title": _("Activities")}
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
return super().get_queryset(**kwargs).distinct()
|
return super().get_queryset(**kwargs).distinct()
|
||||||
|
|
||||||
def get_tables(self):
|
|
||||||
tables = super().get_tables()
|
|
||||||
|
|
||||||
tables[0].prefix = "all-"
|
|
||||||
tables[1].prefix = "upcoming-"
|
|
||||||
return tables
|
|
||||||
|
|
||||||
def get_tables_data(self):
|
def get_tables_data(self):
|
||||||
# first table = all activities, second table = upcoming
|
# first table = all activities, second table = upcoming
|
||||||
return [
|
return [
|
||||||
@ -99,7 +96,7 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Shows details about one activity. Add guest to context
|
Shows details about one activity. Add guest to context
|
||||||
"""
|
"""
|
||||||
@ -107,15 +104,40 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
context_object_name = "activity"
|
context_object_name = "activity"
|
||||||
extra_context = {"title": _("Activity detail")}
|
extra_context = {"title": _("Activity detail")}
|
||||||
|
|
||||||
|
tables = [
|
||||||
|
lambda data: GuestTable(data, prefix="guests-"),
|
||||||
|
lambda data: OpenerTable(data, prefix="opener-"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
return [
|
||||||
|
Guest.objects.filter(activity=self.object)
|
||||||
|
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
|
||||||
|
self.object.opener.filter(activity=self.object)
|
||||||
|
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
|
||||||
|
]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
|
|
||||||
table = GuestTable(data=Guest.objects.filter(activity=self.object)
|
tables = context["tables"]
|
||||||
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
|
for name, table in zip(["guests", "opener"], tables):
|
||||||
context["guests"] = table
|
context[name] = table
|
||||||
|
|
||||||
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
|
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
|
||||||
|
|
||||||
|
context["widget"] = {
|
||||||
|
"name": "opener",
|
||||||
|
"resetable": True,
|
||||||
|
"attrs": {
|
||||||
|
"class": "autocomplete form-control",
|
||||||
|
"id": "opener",
|
||||||
|
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
|
||||||
|
"name_field": "name",
|
||||||
|
"placeholder": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@ -172,12 +194,14 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
|
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
|
||||||
|
|
||||||
|
|
||||||
class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
|
||||||
"""
|
"""
|
||||||
Manages entry to an activity
|
Manages entry to an activity
|
||||||
"""
|
"""
|
||||||
template_name = "activity/activity_entry.html"
|
template_name = "activity/activity_entry.html"
|
||||||
|
|
||||||
|
table_class = EntryTable
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
|
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
|
||||||
@ -212,13 +236,16 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
if "search" in self.request.GET and self.request.GET["search"]:
|
if "search" in self.request.GET and self.request.GET["search"]:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
if pattern[0] != "^":
|
|
||||||
pattern = "^" + pattern
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
pattern = "^" + pattern if valid_regex and pattern[0] != "^" else pattern
|
||||||
guest_qs = guest_qs.filter(
|
guest_qs = guest_qs.filter(
|
||||||
Q(first_name__iregex=pattern)
|
Q(**{f"first_name{suffix}": pattern})
|
||||||
| Q(last_name__iregex=pattern)
|
| Q(**{f"last_name{suffix}": pattern})
|
||||||
| Q(inviter__alias__name__iregex=pattern)
|
| Q(**{f"inviter__alias__name{suffix}": pattern})
|
||||||
| Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern))
|
| Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)})
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
guest_qs = guest_qs.none()
|
guest_qs = guest_qs.none()
|
||||||
@ -238,23 +265,26 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
|||||||
# Keep only users that have a note
|
# Keep only users that have a note
|
||||||
note_qs = note_qs.filter(note__noteuser__isnull=False)
|
note_qs = note_qs.filter(note__noteuser__isnull=False)
|
||||||
|
|
||||||
# Keep only members
|
# Keep only valid members
|
||||||
note_qs = note_qs.filter(
|
note_qs = note_qs.filter(
|
||||||
note__noteuser__user__memberships__club=activity.attendees_club,
|
note__noteuser__user__memberships__club=activity.attendees_club,
|
||||||
note__noteuser__user__memberships__date_start__lte=timezone.now(),
|
note__noteuser__user__memberships__date_start__lte=timezone.now(),
|
||||||
note__noteuser__user__memberships__date_end__gte=timezone.now(),
|
note__noteuser__user__memberships__date_end__gte=timezone.now()).exclude(note__inactivity_reason='forced')
|
||||||
)
|
|
||||||
|
|
||||||
# Filter with permission backend
|
# Filter with permission backend
|
||||||
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, 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"]:
|
if "search" in self.request.GET and self.request.GET["search"]:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
|
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = "__iregex" if valid_regex else "__icontains"
|
||||||
note_qs = note_qs.filter(
|
note_qs = note_qs.filter(
|
||||||
Q(note__noteuser__user__first_name__iregex=pattern)
|
Q(**{f"note__noteuser__user__first_name{suffix}": pattern})
|
||||||
| Q(note__noteuser__user__last_name__iregex=pattern)
|
| Q(**{f"note__noteuser__user__last_name{suffix}": pattern})
|
||||||
| Q(name__iregex=pattern)
|
| Q(**{f"name{suffix}": pattern})
|
||||||
| Q(normalized_name__iregex=Alias.normalize(pattern))
|
| Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)})
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
note_qs = note_qs.none()
|
note_qs = note_qs.none()
|
||||||
@ -266,15 +296,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
|||||||
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
|
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
|
||||||
return note_qs
|
return note_qs
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_table_data(self):
|
||||||
"""
|
|
||||||
Query the list of Guest and Note to the activity and add information to makes entry with JS.
|
|
||||||
"""
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
|
|
||||||
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||||
.distinct().get(pk=self.kwargs["pk"])
|
.distinct().get(pk=self.kwargs["pk"])
|
||||||
context["activity"] = activity
|
|
||||||
|
|
||||||
matched = []
|
matched = []
|
||||||
|
|
||||||
@ -287,8 +311,17 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
|||||||
note.activity = activity
|
note.activity = activity
|
||||||
matched.append(note)
|
matched.append(note)
|
||||||
|
|
||||||
table = EntryTable(data=matched)
|
return matched
|
||||||
context["table"] = table
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Query the list of Guest and Note to the activity and add information to makes entry with JS.
|
||||||
|
"""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||||
|
.distinct().get(pk=self.kwargs["pk"])
|
||||||
|
context["activity"] = activity
|
||||||
|
|
||||||
context["entries"] = Entry.objects.filter(activity=activity)
|
context["entries"] = Entry.objects.filter(activity=activity)
|
||||||
|
|
||||||
@ -330,8 +363,8 @@ X-WR-CALNAME:Kfet Calendar
|
|||||||
NAME:Kfet Calendar
|
NAME:Kfet Calendar
|
||||||
CALSCALE:GREGORIAN
|
CALSCALE:GREGORIAN
|
||||||
BEGIN:VTIMEZONE
|
BEGIN:VTIMEZONE
|
||||||
TZID:Europe/Berlin
|
TZID:Europe/Paris
|
||||||
X-LIC-LOCATION:Europe/Berlin
|
X-LIC-LOCATION:Europe/Paris
|
||||||
BEGIN:DAYLIGHT
|
BEGIN:DAYLIGHT
|
||||||
TZOFFSETFROM:+0100
|
TZOFFSETFROM:+0100
|
||||||
TZOFFSETTO:+0200
|
TZOFFSETTO:+0200
|
||||||
@ -353,10 +386,10 @@ END:VTIMEZONE
|
|||||||
DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z
|
DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z
|
||||||
UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
|
UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
|
||||||
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
|
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
|
||||||
DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}
|
DTSTART:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_start)}
|
||||||
DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)}
|
DTEND:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_end)}
|
||||||
LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
|
LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
|
||||||
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + """
|
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + f"""
|
||||||
-- {activity.organizer.name}
|
-- {activity.organizer.name}
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
"""
|
"""
|
||||||
|
42
apps/api/filters.py
Normal file
42
apps/api/filters.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from rest_framework.filters import SearchFilter
|
||||||
|
|
||||||
|
|
||||||
|
class RegexSafeSearchFilter(SearchFilter):
|
||||||
|
@lru_cache
|
||||||
|
def validate_regex(self, search_term) -> bool:
|
||||||
|
try:
|
||||||
|
re.compile(search_term)
|
||||||
|
return True
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_search_fields(self, view, request):
|
||||||
|
"""
|
||||||
|
Ensure that given regex are valid.
|
||||||
|
If not, we consider that the user is trying to search by substring.
|
||||||
|
"""
|
||||||
|
search_fields = super().get_search_fields(view, request)
|
||||||
|
search_terms = self.get_search_terms(request)
|
||||||
|
|
||||||
|
for search_term in search_terms:
|
||||||
|
if not self.validate_regex(search_term):
|
||||||
|
# Invalid regex. We assume we don't query by regex but by substring.
|
||||||
|
search_fields = [f.replace('$', '') for f in search_fields]
|
||||||
|
break
|
||||||
|
|
||||||
|
return search_fields
|
||||||
|
|
||||||
|
def get_search_terms(self, request):
|
||||||
|
"""
|
||||||
|
Ensure that search field is a valid regex query. If not, we remove extra characters.
|
||||||
|
"""
|
||||||
|
terms = super().get_search_terms(request)
|
||||||
|
if not all(self.validate_regex(term) for term in terms):
|
||||||
|
# Invalid regex. If a ^ is prefixed to the search term, we remove it.
|
||||||
|
terms = [term[1:] if term[0] == '^' else term for term in terms]
|
||||||
|
# Same for dollars.
|
||||||
|
terms = [term[:-1] if term[-1] == '$' else term for term in terms]
|
||||||
|
return terms
|
@ -12,11 +12,12 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.db.models.fields.files import ImageFieldFile
|
from django.db.models.fields.files import ImageFieldFile
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from phonenumbers import PhoneNumber
|
||||||
|
from rest_framework.filters import OrderingFilter
|
||||||
|
from api.filters import RegexSafeSearchFilter
|
||||||
from member.models import Membership, Club
|
from member.models import Membership, Club
|
||||||
from note.models import NoteClub, NoteUser, Alias, Note
|
from note.models import NoteClub, NoteUser, Alias, Note
|
||||||
from permission.models import PermissionMask, Permission, Role
|
from permission.models import PermissionMask, Permission, Role
|
||||||
from phonenumbers import PhoneNumber
|
|
||||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
|
||||||
|
|
||||||
from .viewsets import ContentTypeViewSet, UserViewSet
|
from .viewsets import ContentTypeViewSet, UserViewSet
|
||||||
|
|
||||||
@ -87,7 +88,7 @@ class TestAPI(TestCase):
|
|||||||
resp = self.client.get(url + f"?ordering=-{field}")
|
resp = self.client.get(url + f"?ordering=-{field}")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
if SearchFilter in backends:
|
if RegexSafeSearchFilter in backends:
|
||||||
# Basic search
|
# Basic search
|
||||||
for field in viewset.search_fields:
|
for field in viewset.search_fields:
|
||||||
obj = self.fix_note_object(obj, field)
|
obj = self.fix_note_object(obj, field)
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import include
|
||||||
|
from django.urls import re_path
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
from .views import UserInformationView
|
from .views import UserInformationView
|
||||||
@ -14,29 +15,33 @@ router = routers.DefaultRouter()
|
|||||||
router.register('models', ContentTypeViewSet)
|
router.register('models', ContentTypeViewSet)
|
||||||
router.register('user', UserViewSet)
|
router.register('user', UserViewSet)
|
||||||
|
|
||||||
|
if "activity" in settings.INSTALLED_APPS:
|
||||||
|
from activity.api.urls import register_activity_urls
|
||||||
|
register_activity_urls(router, 'activity')
|
||||||
|
|
||||||
|
if "food" in settings.INSTALLED_APPS:
|
||||||
|
from food.api.urls import register_food_urls
|
||||||
|
register_food_urls(router, 'food')
|
||||||
|
|
||||||
|
if "logs" in settings.INSTALLED_APPS:
|
||||||
|
from logs.api.urls import register_logs_urls
|
||||||
|
register_logs_urls(router, 'logs')
|
||||||
|
|
||||||
if "member" in settings.INSTALLED_APPS:
|
if "member" in settings.INSTALLED_APPS:
|
||||||
from member.api.urls import register_members_urls
|
from member.api.urls import register_members_urls
|
||||||
register_members_urls(router, 'members')
|
register_members_urls(router, 'members')
|
||||||
|
|
||||||
if "member" in settings.INSTALLED_APPS:
|
|
||||||
from activity.api.urls import register_activity_urls
|
|
||||||
register_activity_urls(router, 'activity')
|
|
||||||
|
|
||||||
if "note" in settings.INSTALLED_APPS:
|
if "note" in settings.INSTALLED_APPS:
|
||||||
from note.api.urls import register_note_urls
|
from note.api.urls import register_note_urls
|
||||||
register_note_urls(router, 'note')
|
register_note_urls(router, 'note')
|
||||||
|
|
||||||
if "treasury" in settings.INSTALLED_APPS:
|
|
||||||
from treasury.api.urls import register_treasury_urls
|
|
||||||
register_treasury_urls(router, 'treasury')
|
|
||||||
|
|
||||||
if "permission" in settings.INSTALLED_APPS:
|
if "permission" in settings.INSTALLED_APPS:
|
||||||
from permission.api.urls import register_permission_urls
|
from permission.api.urls import register_permission_urls
|
||||||
register_permission_urls(router, 'permission')
|
register_permission_urls(router, 'permission')
|
||||||
|
|
||||||
if "logs" in settings.INSTALLED_APPS:
|
if "treasury" in settings.INSTALLED_APPS:
|
||||||
from logs.api.urls import register_logs_urls
|
from treasury.api.urls import register_treasury_urls
|
||||||
register_logs_urls(router, 'logs')
|
register_treasury_urls(router, 'treasury')
|
||||||
|
|
||||||
if "wei" in settings.INSTALLED_APPS:
|
if "wei" in settings.INSTALLED_APPS:
|
||||||
from wei.api.urls import register_wei_urls
|
from wei.api.urls import register_wei_urls
|
||||||
@ -47,7 +52,7 @@ app_name = 'api'
|
|||||||
# Wire up our API using automatic URL routing.
|
# Wire up our API using automatic URL routing.
|
||||||
# Additionally, we include login URLs for the browsable API.
|
# Additionally, we include login URLs for the browsable API.
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url('^', include(router.urls)),
|
re_path('^', include(router.urls)),
|
||||||
url('^me/', UserInformationView.as_view()),
|
re_path('^me/', UserInformationView.as_view()),
|
||||||
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
re_path('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||||
]
|
]
|
||||||
|
@ -1,19 +1,29 @@
|
|||||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from rest_framework.filters import SearchFilter
|
|
||||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
|
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
|
||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
from note.models import Alias
|
from note.models import Alias
|
||||||
|
|
||||||
|
from .filters import RegexSafeSearchFilter
|
||||||
from .serializers import UserSerializer, ContentTypeSerializer
|
from .serializers import UserSerializer, ContentTypeSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def is_regex(pattern):
|
||||||
|
try:
|
||||||
|
re.compile(pattern)
|
||||||
|
return True
|
||||||
|
except (re.error, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ReadProtectedModelViewSet(ModelViewSet):
|
class ReadProtectedModelViewSet(ModelViewSet):
|
||||||
"""
|
"""
|
||||||
Protect a ModelViewSet by filtering the objects that the user cannot see.
|
Protect a ModelViewSet by filtering the objects that the user cannot see.
|
||||||
@ -60,34 +70,38 @@ class UserViewSet(ReadProtectedModelViewSet):
|
|||||||
|
|
||||||
if "search" in self.request.GET:
|
if "search" in self.request.GET:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
|
|
||||||
# Filter with different rules
|
# Filter with different rules
|
||||||
# We use union-all to keep each filter rule sorted in result
|
# We use union-all to keep each filter rule sorted in result
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
# Match without normalization
|
# Match without normalization
|
||||||
note__alias__name__iregex="^" + pattern
|
Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||||
).union(
|
).union(
|
||||||
queryset.filter(
|
queryset.filter(
|
||||||
# Match with normalization
|
# Match with normalization
|
||||||
Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||||
& ~Q(note__alias__name__iregex="^" + pattern)
|
& ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||||
),
|
),
|
||||||
all=True,
|
all=True,
|
||||||
).union(
|
).union(
|
||||||
queryset.filter(
|
queryset.filter(
|
||||||
# Match on lower pattern
|
# Match on lower pattern
|
||||||
Q(note__alias__normalized_name__iregex="^" + pattern.lower())
|
Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
|
||||||
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||||
& ~Q(note__alias__name__iregex="^" + pattern)
|
& ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||||
),
|
),
|
||||||
all=True,
|
all=True,
|
||||||
).union(
|
).union(
|
||||||
queryset.filter(
|
queryset.filter(
|
||||||
# Match on firstname or lastname
|
# Match on firstname or lastname
|
||||||
(Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern))
|
(Q(**{f"last_name{suffix}": prefix + pattern}) | Q(**{f"first_name{suffix}": prefix + pattern}))
|
||||||
& ~Q(note__alias__normalized_name__iregex="^" + pattern.lower())
|
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
|
||||||
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||||
& ~Q(note__alias__name__iregex="^" + pattern)
|
& ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||||
),
|
),
|
||||||
all=True,
|
all=True,
|
||||||
)
|
)
|
||||||
@ -107,6 +121,6 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = ContentType.objects.order_by('id')
|
queryset = ContentType.objects.order_by('id')
|
||||||
serializer_class = ContentTypeSerializer
|
serializer_class = ContentTypeSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['id', 'app_label', 'model', ]
|
filterset_fields = ['id', 'app_label', 'model', ]
|
||||||
search_fields = ['$app_label', '$model', ]
|
search_fields = ['$app_label', '$model', ]
|
||||||
|
0
apps/food/__init__.py
Normal file
0
apps/food/__init__.py
Normal file
37
apps/food/admin.py
Normal file
37
apps/food/admin.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.db import transaction
|
||||||
|
from note_kfet.admin import admin_site
|
||||||
|
|
||||||
|
from .models import Allergen, BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(QRCode, site=admin_site)
|
||||||
|
class QRCodeAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BasicFood, site=admin_site)
|
||||||
|
class BasicFoodAdmin(admin.ModelAdmin):
|
||||||
|
@transaction.atomic
|
||||||
|
def save_related(self, *args, **kwargs):
|
||||||
|
ans = super().save_related(*args, **kwargs)
|
||||||
|
args[1].instance.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TransformedFood, site=admin_site)
|
||||||
|
class TransformedFoodAdmin(admin.ModelAdmin):
|
||||||
|
exclude = ["allergens", "expiry_date"]
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save_related(self, request, form, *args, **kwargs):
|
||||||
|
super().save_related(request, form, *args, **kwargs)
|
||||||
|
form.instance.update()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Allergen, site=admin_site)
|
||||||
|
class AllergenAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
0
apps/food/api/__init__.py
Normal file
0
apps/food/api/__init__.py
Normal file
50
apps/food/api/serializers.py
Normal file
50
apps/food/api/serializers.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
class AllergenSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Allergen.
|
||||||
|
The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Allergen
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFoodSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for BasicFood.
|
||||||
|
The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BasicFood
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for QRCode.
|
||||||
|
The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = QRCode
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for TransformedFood.
|
||||||
|
The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TransformedFood
|
||||||
|
fields = '__all__'
|
14
apps/food/api/urls.py
Normal file
14
apps/food/api/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from .views import AllergenViewSet, BasicFoodViewSet, QRCodeViewSet, TransformedFoodViewSet
|
||||||
|
|
||||||
|
|
||||||
|
def register_food_urls(router, path):
|
||||||
|
"""
|
||||||
|
Configure router for Food REST API.
|
||||||
|
"""
|
||||||
|
router.register(path + '/allergen', AllergenViewSet)
|
||||||
|
router.register(path + '/basic_food', BasicFoodViewSet)
|
||||||
|
router.register(path + '/qrcode', QRCodeViewSet)
|
||||||
|
router.register(path + '/transformed_food', TransformedFoodViewSet)
|
61
apps/food/api/views.py
Normal file
61
apps/food/api/views.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework.filters import SearchFilter
|
||||||
|
|
||||||
|
from .serializers import AllergenSerializer, BasicFoodSerializer, QRCodeSerializer, TransformedFoodSerializer
|
||||||
|
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
class AllergenViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `Allergen` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/food/allergen/
|
||||||
|
"""
|
||||||
|
queryset = Allergen.objects.order_by('id')
|
||||||
|
serializer_class = AllergenSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||||
|
filterset_fields = ['name', ]
|
||||||
|
search_fields = ['$name', ]
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFoodViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/food/basic_food/
|
||||||
|
"""
|
||||||
|
queryset = BasicFood.objects.order_by('id')
|
||||||
|
serializer_class = BasicFoodSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||||
|
filterset_fields = ['name', ]
|
||||||
|
search_fields = ['$name', ]
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `QRCode` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/food/qrcode/
|
||||||
|
"""
|
||||||
|
queryset = QRCode.objects.order_by('id')
|
||||||
|
serializer_class = QRCodeSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||||
|
filterset_fields = ['qr_code_number', ]
|
||||||
|
search_fields = ['$qr_code_number', ]
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/food/transformed_food/
|
||||||
|
"""
|
||||||
|
queryset = TransformedFood.objects.order_by('id')
|
||||||
|
serializer_class = TransformedFoodSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||||
|
filterset_fields = ['name', ]
|
||||||
|
search_fields = ['$name', ]
|
11
apps/food/apps.py
Normal file
11
apps/food/apps.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FoodkfetConfig(AppConfig):
|
||||||
|
name = 'food'
|
||||||
|
verbose_name = _('food')
|
114
apps/food/forms.py
Normal file
114
apps/food/forms.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from random import shuffle
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils import timezone
|
||||||
|
from member.models import Club
|
||||||
|
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||||
|
from note_kfet.inputs import Autocomplete
|
||||||
|
from note_kfet.middlewares import get_current_request
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
|
from .models import BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
class AddIngredientForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for add an ingredient
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['ingredient'].queryset = self.fields['ingredient'].queryset.filter(
|
||||||
|
polymorphic_ctype__model='transformedfood',
|
||||||
|
is_ready=False,
|
||||||
|
is_active=True,
|
||||||
|
was_eaten=False,
|
||||||
|
)
|
||||||
|
# Caution, the logic is inverted here, we flip the logic on saving in AddIngredientView
|
||||||
|
self.fields['is_active'].initial = True
|
||||||
|
self.fields['is_active'].label = _("Fully used")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TransformedFood
|
||||||
|
fields = ('ingredient', 'is_active')
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFoodForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for add non-transformed food
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
|
||||||
|
self.fields['name'].required = True
|
||||||
|
self.fields['owner'].required = True
|
||||||
|
|
||||||
|
# Some example
|
||||||
|
self.fields['name'].widget.attrs.update({"placeholder": _("Pasta METRO 5kg")})
|
||||||
|
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||||
|
shuffle(clubs)
|
||||||
|
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BasicFood
|
||||||
|
fields = ('name', 'owner', 'date_type', 'expiry_date', 'is_active', 'was_eaten', 'allergens',)
|
||||||
|
widgets = {
|
||||||
|
"owner": Autocomplete(
|
||||||
|
model=Club,
|
||||||
|
attrs={"api_url": "/api/members/club/"},
|
||||||
|
),
|
||||||
|
'expiry_date': DateTimePickerInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for create QRCode
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
|
||||||
|
is_active=True,
|
||||||
|
was_eaten=False,
|
||||||
|
polymorphic_ctype__model='transformedfood',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = QRCode
|
||||||
|
fields = ('food_container',)
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for add transformed food
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
|
||||||
|
self.fields['name'].required = True
|
||||||
|
self.fields['owner'].required = True
|
||||||
|
self.fields['creation_date'].required = True
|
||||||
|
self.fields['creation_date'].initial = timezone.now
|
||||||
|
self.fields['is_active'].initial = True
|
||||||
|
self.fields['is_ready'].initial = False
|
||||||
|
self.fields['was_eaten'].initial = False
|
||||||
|
|
||||||
|
# Some example
|
||||||
|
self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
|
||||||
|
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||||
|
shuffle(clubs)
|
||||||
|
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TransformedFood
|
||||||
|
fields = ('name', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life')
|
||||||
|
widgets = {
|
||||||
|
"owner": Autocomplete(
|
||||||
|
model=Club,
|
||||||
|
attrs={"api_url": "/api/members/club/"},
|
||||||
|
),
|
||||||
|
'creation_date': DateTimePickerInput(),
|
||||||
|
}
|
84
apps/food/migrations/0001_initial.py
Normal file
84
apps/food/migrations/0001_initial.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-07-05 08:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('member', '0011_profile_vss_charter_read'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Allergen',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Allergen',
|
||||||
|
'verbose_name_plural': 'Allergens',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Food',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
('expiry_date', models.DateTimeField(verbose_name='expiry date')),
|
||||||
|
('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')),
|
||||||
|
('is_ready', models.BooleanField(default=False, verbose_name='is ready')),
|
||||||
|
('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='owner')),
|
||||||
|
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_food.food_set+', to='contenttypes.ContentType')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'foods',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BasicFood',
|
||||||
|
fields=[
|
||||||
|
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
|
||||||
|
('date_type', models.CharField(choices=[('DLC', 'DLC'), ('DDM', 'DDM')], max_length=255)),
|
||||||
|
('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Basic food',
|
||||||
|
'verbose_name_plural': 'Basic foods',
|
||||||
|
},
|
||||||
|
bases=('food.food',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='QRCode',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('qr_code_number', models.PositiveIntegerField(unique=True, verbose_name='QR-code number')),
|
||||||
|
('food_container', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='QR_code', to='food.Food', verbose_name='food container')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'QR-code',
|
||||||
|
'verbose_name_plural': 'QR-codes',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TransformedFood',
|
||||||
|
fields=[
|
||||||
|
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
|
||||||
|
('creation_date', models.DateTimeField(verbose_name='creation date')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='is active')),
|
||||||
|
('ingredient', models.ManyToManyField(blank=True, related_name='transformed_ingredient_inv', to='food.Food', verbose_name='transformed ingredient')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Transformed food',
|
||||||
|
'verbose_name_plural': 'Transformed foods',
|
||||||
|
},
|
||||||
|
bases=('food.food',),
|
||||||
|
),
|
||||||
|
]
|
19
apps/food/migrations/0002_transformedfood_shelf_life.py
Normal file
19
apps/food/migrations/0002_transformedfood_shelf_life.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-07-06 20:37
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('food', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transformedfood',
|
||||||
|
name='shelf_life',
|
||||||
|
field=models.DurationField(default=datetime.timedelta(days=3), verbose_name='shelf life'),
|
||||||
|
),
|
||||||
|
]
|
62
apps/food/migrations/0003_create_14_allergens_mandatory.py
Normal file
62
apps/food/migrations/0003_create_14_allergens_mandatory.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
def create_14_mandatory_allergens(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
There are 14 mandatory allergens, they are pre-injected
|
||||||
|
"""
|
||||||
|
|
||||||
|
Allergen = apps.get_model("food", "allergen")
|
||||||
|
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Gluten",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Fruits à coques",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Crustacés",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Céléri",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Oeufs",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Moutarde",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Poissons",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Soja",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Lait",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Sulfites",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Sésame",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Lupin",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Arachides",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Mollusques",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('food', '0002_transformedfood_shelf_life'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_14_mandatory_allergens),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
28
apps/food/migrations/0004_auto_20240813_2358.py
Normal file
28
apps/food/migrations/0004_auto_20240813_2358.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-08-13 21:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('food', '0003_create_14_allergens_mandatory'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='transformedfood',
|
||||||
|
name='is_active',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='is active'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='qrcode',
|
||||||
|
name='food_container',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='QR_code', to='food.Food', verbose_name='food container'),
|
||||||
|
),
|
||||||
|
]
|
20
apps/food/migrations/0005_alter_food_polymorphic_ctype.py
Normal file
20
apps/food/migrations/0005_alter_food_polymorphic_ctype.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-08-28 08:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('food', '0004_auto_20240813_2358'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='food',
|
||||||
|
name='polymorphic_ctype',
|
||||||
|
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
|
||||||
|
),
|
||||||
|
]
|
0
apps/food/migrations/__init__.py
Normal file
0
apps/food/migrations/__init__.py
Normal file
226
apps/food/models.py
Normal file
226
apps/food/models.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db import models, transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from member.models import Club
|
||||||
|
from polymorphic.models import PolymorphicModel
|
||||||
|
|
||||||
|
|
||||||
|
class QRCode(models.Model):
|
||||||
|
"""
|
||||||
|
An QRCode model
|
||||||
|
"""
|
||||||
|
qr_code_number = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("QR-code number"),
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
food_container = models.ForeignKey(
|
||||||
|
'Food',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='QR_code',
|
||||||
|
verbose_name=_('food container'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("QR-code")
|
||||||
|
verbose_name_plural = _("QR-codes")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return _("QR-code number {qr_code_number}").format(qr_code_number=self.qr_code_number)
|
||||||
|
|
||||||
|
|
||||||
|
class Allergen(models.Model):
|
||||||
|
"""
|
||||||
|
A list of allergen and alimentary restrictions
|
||||||
|
"""
|
||||||
|
name = models.CharField(
|
||||||
|
verbose_name=_('name'),
|
||||||
|
max_length=255,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Allergen')
|
||||||
|
verbose_name_plural = _('Allergens')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Food(PolymorphicModel):
|
||||||
|
name = models.CharField(
|
||||||
|
verbose_name=_('name'),
|
||||||
|
max_length=255,
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
Club,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='+',
|
||||||
|
verbose_name=_('owner'),
|
||||||
|
)
|
||||||
|
|
||||||
|
allergens = models.ManyToManyField(
|
||||||
|
Allergen,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('allergen'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expiry_date = models.DateTimeField(
|
||||||
|
verbose_name=_('expiry date'),
|
||||||
|
null=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
was_eaten = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('was eaten'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# is_ready != is_active : is_ready signifie que la nourriture est prête à être manger,
|
||||||
|
# is_active signifie que la nourriture n'est pas encore archivé
|
||||||
|
# il sert dans les cas où il est plus intéressant que de l'open soit conservé (confiture par ex)
|
||||||
|
|
||||||
|
is_ready = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('is ready'),
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_('is active'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
||||||
|
return super().save(force_insert, force_update, using, update_fields)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('food')
|
||||||
|
verbose_name = _('foods')
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFood(Food):
|
||||||
|
"""
|
||||||
|
Food which has been directly buy on supermarket
|
||||||
|
"""
|
||||||
|
date_type = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
choices=(
|
||||||
|
("DLC", "DLC"),
|
||||||
|
("DDM", "DDM"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
arrival_date = models.DateTimeField(
|
||||||
|
verbose_name=_('arrival date'),
|
||||||
|
default=timezone.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
# label = models.ImageField(
|
||||||
|
# verbose_name=_('food label'),
|
||||||
|
# max_length=255,
|
||||||
|
# blank=False,
|
||||||
|
# null=False,
|
||||||
|
# upload_to='label/',
|
||||||
|
# )
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_allergens(self):
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_allergens()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_expiry_date(self):
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_expiry_date()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self):
|
||||||
|
self.update_allergens()
|
||||||
|
self.update_expiry_date()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Basic food')
|
||||||
|
verbose_name_plural = _('Basic foods')
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFood(Food):
|
||||||
|
"""
|
||||||
|
Transformed food are a mix between basic food and meal
|
||||||
|
"""
|
||||||
|
creation_date = models.DateTimeField(
|
||||||
|
verbose_name=_('creation date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
ingredient = models.ManyToManyField(
|
||||||
|
Food,
|
||||||
|
blank=True,
|
||||||
|
symmetrical=False,
|
||||||
|
related_name='transformed_ingredient_inv',
|
||||||
|
verbose_name=_('transformed ingredient'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without microbiological analyzes, the storage time is 3 days
|
||||||
|
shelf_life = models.DurationField(
|
||||||
|
verbose_name=_("shelf life"),
|
||||||
|
default=timedelta(days=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def archive(self):
|
||||||
|
# When a meal are archived, if it was eaten, update ingredient fully used for this meal
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_allergens(self):
|
||||||
|
# When allergens are changed, simply update the parents' allergens
|
||||||
|
old_allergens = list(self.allergens.all())
|
||||||
|
self.allergens.clear()
|
||||||
|
for ingredient in self.ingredient.iterator():
|
||||||
|
self.allergens.set(self.allergens.union(ingredient.allergens.all()))
|
||||||
|
|
||||||
|
if old_allergens == list(self.allergens.all()):
|
||||||
|
return
|
||||||
|
super().save()
|
||||||
|
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_allergens()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_expiry_date(self):
|
||||||
|
# When expiry_date is changed, simply update the parents' expiry_date
|
||||||
|
old_expiry_date = self.expiry_date
|
||||||
|
self.expiry_date = self.creation_date + self.shelf_life
|
||||||
|
for ingredient in self.ingredient.iterator():
|
||||||
|
self.expiry_date = min(self.expiry_date, ingredient.expiry_date)
|
||||||
|
|
||||||
|
if old_expiry_date == self.expiry_date:
|
||||||
|
return
|
||||||
|
super().save()
|
||||||
|
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_expiry_date()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self):
|
||||||
|
self.update_allergens()
|
||||||
|
self.update_expiry_date()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Transformed food')
|
||||||
|
verbose_name_plural = _('Transformed foods')
|
19
apps/food/tables.py
Normal file
19
apps/food/tables.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import django_tables2 as tables
|
||||||
|
from django_tables2 import A
|
||||||
|
|
||||||
|
from .models import TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodTable(tables.Table):
|
||||||
|
name = tables.LinkColumn(
|
||||||
|
'food:food_view',
|
||||||
|
args=[A('pk'), ],
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TransformedFood
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
fields = ('name', "owner", "allergens", "expiry_date")
|
20
apps/food/templates/food/add_ingredient_form.html
Normal file
20
apps/food/templates/food/add_ingredient_form.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body" id="form">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
37
apps/food/templates/food/basicfood_detail.html
Normal file
37
apps/food/templates/food/basicfood_detail.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }} {{ food.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
|
||||||
|
<li><p>{% trans 'Arrival date' %} : {{ food.arrival_date }}</p></li>
|
||||||
|
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }} ({{ food.date_type }})</p></li>
|
||||||
|
<li>{% trans 'Allergens' %} :</li>
|
||||||
|
<ul>
|
||||||
|
{% for allergen in food.allergens.iterator %}
|
||||||
|
<li>{{ allergen.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<li><p>{% trans 'Active' %} : {{ food.is_active }}<p></li>
|
||||||
|
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}<p></li>
|
||||||
|
</ul>
|
||||||
|
{% if can_update %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=food.pk %}">{% trans 'Update' %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_add_ingredient %}
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||||
|
{% trans 'Add to a meal' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
20
apps/food/templates/food/basicfood_form.html
Normal file
20
apps/food/templates/food/basicfood_form.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body" id="form">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form | crispy }}
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
55
apps/food/templates/food/create_qrcode_form.html
Normal file
55
apps/food/templates/food/create_qrcode_form.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body" id="form">
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:qrcode_basic_create" slug=slug %}">
|
||||||
|
{% trans 'New basic food' %}
|
||||||
|
</a>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||||
|
</form>
|
||||||
|
<div class="card-body" id="profile_infos">
|
||||||
|
<h4>{% trans "Copy constructor" %}</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Name" %}
|
||||||
|
</th>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Owner" %}
|
||||||
|
</th>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Arrival date" %}
|
||||||
|
</th>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Expiry date" %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for basic in last_basic %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url "food:qrcode_basic_create" slug=slug %}?copy={{ basic.pk }}">{{ basic.name }}</a></td>
|
||||||
|
<td>{{ basic.owner }}</td>
|
||||||
|
<td>{{ basic.arrival_date }}</td>
|
||||||
|
<td>{{ basic.expiry_date }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
39
apps/food/templates/food/qrcode_detail.html
Normal file
39
apps/food/templates/food/qrcode_detail.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }} {% trans 'number' %} {{ qrcode.qr_code_number }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<li><p>{% trans 'Name' %} : {{ qrcode.food_container.name }}</p></li>
|
||||||
|
<li><p>{% trans 'Owner' %} : {{ qrcode.food_container.owner }}</p></li>
|
||||||
|
<li><p>{% trans 'Expiry date' %} : {{ qrcode.food_container.expiry_date }}</p></li>
|
||||||
|
</ul>
|
||||||
|
{% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=qrcode.food_container.pk %}" data-turbolinks="false">
|
||||||
|
{% trans 'Update' %}
|
||||||
|
</a>
|
||||||
|
{% elif can_update_transformed %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=qrcode.food_container.pk %}">
|
||||||
|
{% trans 'Update' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_view_detail %}
|
||||||
|
<a class="btn btn-sm btn-primary" href="{% url "food:food_view" pk=qrcode.food_container.pk %}">
|
||||||
|
{% trans 'View details' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_add_ingredient %}
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=qrcode.food_container.pk %}">
|
||||||
|
{% trans 'Add to a meal' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
51
apps/food/templates/food/transformedfood_detail.html
Normal file
51
apps/food/templates/food/transformedfood_detail.html
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }} {{ food.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
|
||||||
|
{% if can_see_ready %}
|
||||||
|
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><p>{% trans 'Creation date' %} : {{ food.creation_date }}</p></li>
|
||||||
|
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }}</p></li>
|
||||||
|
<li>{% trans 'Allergens' %} :</li>
|
||||||
|
<ul>
|
||||||
|
{% for allergen in food.allergens.iterator %}
|
||||||
|
<li>{{ allergen.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<li>{% trans 'Ingredients' %} :</li>
|
||||||
|
<ul>
|
||||||
|
{% for ingredient in food.ingredient.iterator %}
|
||||||
|
<li><a href="{% url "food:food_view" pk=ingredient.pk %}">{{ ingredient.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<li><p>{% trans 'Shelf life' %} : {{ food.shelf_life }}</p></li>
|
||||||
|
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
|
||||||
|
<li><p>{% trans 'Active' %} : {{ food.is_active }}</p></li>
|
||||||
|
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}</p></li>
|
||||||
|
</ul>
|
||||||
|
{% if can_update %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=food.pk %}">
|
||||||
|
{% trans 'Update' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_add_ingredient %}
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||||
|
{% trans 'Add to a meal' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
20
apps/food/templates/food/transformedfood_form.html
Normal file
20
apps/food/templates/food/transformedfood_form.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body" id="form">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
60
apps/food/templates/food/transformedfood_list.html
Normal file
60
apps/food/templates/food/transformedfood_list.html
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "Meal served" %}
|
||||||
|
</h3>
|
||||||
|
{% if can_create_meal %}
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url 'food:transformed_create' %}" data-turbolinks="false">
|
||||||
|
{% trans 'New meal' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if served.data %}
|
||||||
|
{% render_table served %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "There is no meal served." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "Open" %}
|
||||||
|
</h3>
|
||||||
|
{% if open.data %}
|
||||||
|
{% render_table open %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "There is no free meal." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "All meals" %}
|
||||||
|
</h3>
|
||||||
|
{% if table.data %}
|
||||||
|
{% render_table table %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "There is no meal." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
3
apps/food/tests.py
Normal file
3
apps/food/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
21
apps/food/urls.py
Normal file
21
apps/food/urls.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'food'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.TransformedListView.as_view(), name='food_list'),
|
||||||
|
path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'),
|
||||||
|
path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'),
|
||||||
|
|
||||||
|
path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||||
|
path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'),
|
||||||
|
path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'),
|
||||||
|
path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'),
|
||||||
|
path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'),
|
||||||
|
path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
||||||
|
]
|
421
apps/food/views.py
Normal file
421
apps/food/views.py
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django_tables2.views import MultiTableMixin
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import DetailView, UpdateView
|
||||||
|
from django.views.generic.list import ListView
|
||||||
|
from django.forms import HiddenInput
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||||
|
|
||||||
|
from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms
|
||||||
|
from .models import BasicFood, Food, QRCode, TransformedFood
|
||||||
|
from .tables import TransformedFoodTable
|
||||||
|
|
||||||
|
|
||||||
|
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
A view to add an ingredient
|
||||||
|
"""
|
||||||
|
model = Food
|
||||||
|
template_name = 'food/add_ingredient_form.html'
|
||||||
|
extra_context = {"title": _("Add the ingredient")}
|
||||||
|
form_class = AddIngredientForms
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["pk"] = self.kwargs["pk"]
|
||||||
|
return context
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||||
|
add_ingredient_form = AddIngredientForms(data=self.request.POST)
|
||||||
|
if food.is_ready:
|
||||||
|
form.add_error(None, _("The product is already prepared"))
|
||||||
|
return self.form_invalid(form)
|
||||||
|
if not add_ingredient_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# We flip logic ""fully used = not is_active""
|
||||||
|
food.is_active = not food.is_active
|
||||||
|
# Save the aliment and the allergens associed
|
||||||
|
for transformed_pk in self.request.POST.getlist('ingredient'):
|
||||||
|
transformed = TransformedFood.objects.get(pk=transformed_pk)
|
||||||
|
if not transformed.is_ready:
|
||||||
|
transformed.ingredient.add(food)
|
||||||
|
transformed.update()
|
||||||
|
food.save()
|
||||||
|
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
return reverse('food:food_list')
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
A view to update a basic food
|
||||||
|
"""
|
||||||
|
model = BasicFood
|
||||||
|
form_class = BasicFoodForms
|
||||||
|
template_name = 'food/basicfood_form.html'
|
||||||
|
extra_context = {"title": _("Update an aliment")}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
basic_food_form = BasicFoodForms(data=self.request.POST)
|
||||||
|
if not basic_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
ans = super().form_valid(form)
|
||||||
|
form.instance.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
A view to see a food
|
||||||
|
"""
|
||||||
|
model = Food
|
||||||
|
extra_context = {"title": _("Details of:")}
|
||||||
|
context_object_name = "food"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
||||||
|
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
#####################################################################
|
||||||
|
# TO DO
|
||||||
|
# - this feature is very pratical for meat or fish, nevertheless we can implement this later
|
||||||
|
# - fix picture save
|
||||||
|
# - implement solution crop and convert image (reuse or recode ImageForm from members apps)
|
||||||
|
#####################################################################
|
||||||
|
"""
|
||||||
|
A view to add a basic food with a qrcode
|
||||||
|
"""
|
||||||
|
model = BasicFood
|
||||||
|
form_class = BasicFoodForms
|
||||||
|
template_name = 'food/basicfood_form.html'
|
||||||
|
extra_context = {"title": _("Add a new basic food with QRCode")}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
basic_food_form = BasicFoodForms(data=self.request.POST)
|
||||||
|
if not basic_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# Save the aliment and the allergens associed
|
||||||
|
basic_food = form.save(commit=False)
|
||||||
|
# We assume the date of labeling and the same as the date of arrival
|
||||||
|
basic_food.arrival_date = timezone.now()
|
||||||
|
basic_food.is_ready = False
|
||||||
|
basic_food.is_active = True
|
||||||
|
basic_food.was_eaten = False
|
||||||
|
basic_food._force_save = True
|
||||||
|
basic_food.save()
|
||||||
|
basic_food.refresh_from_db()
|
||||||
|
|
||||||
|
qrcode = QRCode()
|
||||||
|
qrcode.qr_code_number = self.kwargs['slug']
|
||||||
|
qrcode.food_container = basic_food
|
||||||
|
qrcode.save()
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
|
||||||
|
# We choose a club which may work or BDE else
|
||||||
|
owner_id = 1
|
||||||
|
for membership in self.request.user.memberships.all():
|
||||||
|
club_id = membership.club.id
|
||||||
|
food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id)
|
||||||
|
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
|
||||||
|
owner_id = club_id
|
||||||
|
|
||||||
|
return BasicFood(
|
||||||
|
name="",
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
# Some field are hidden on create
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
form = context['form']
|
||||||
|
form.fields['is_active'].widget = HiddenInput()
|
||||||
|
form.fields['was_eaten'].widget = HiddenInput()
|
||||||
|
|
||||||
|
copy = self.request.GET.get('copy', None)
|
||||||
|
if copy is not None:
|
||||||
|
basic = BasicFood.objects.get(pk=copy)
|
||||||
|
for field in ['date_type', 'expiry_date', 'name', 'owner']:
|
||||||
|
form.fields[field].initial = getattr(basic, field)
|
||||||
|
for field in ['allergens']:
|
||||||
|
form.fields[field].initial = getattr(basic, field).all()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
A view to add a new qrcode
|
||||||
|
"""
|
||||||
|
model = QRCode
|
||||||
|
template_name = 'food/create_qrcode_form.html'
|
||||||
|
form_class = QRCodeForms
|
||||||
|
extra_context = {"title": _("Add a new QRCode")}
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
qrcode = kwargs["slug"]
|
||||||
|
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
|
||||||
|
return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs))
|
||||||
|
else:
|
||||||
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["slug"] = self.kwargs["slug"]
|
||||||
|
|
||||||
|
context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10]
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
qrcode_food_form = QRCodeForms(data=self.request.POST)
|
||||||
|
if not qrcode_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# Save the qrcode
|
||||||
|
qrcode = form.save(commit=False)
|
||||||
|
qrcode.qr_code_number = self.kwargs["slug"]
|
||||||
|
qrcode._force_save = True
|
||||||
|
qrcode.save()
|
||||||
|
qrcode.refresh_from_db()
|
||||||
|
|
||||||
|
qrcode.food_container.save()
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
return QRCode(
|
||||||
|
qr_code_number=self.kwargs["slug"],
|
||||||
|
food_container_id=1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
A view to see a qrcode
|
||||||
|
"""
|
||||||
|
model = QRCode
|
||||||
|
extra_context = {"title": _("QRCode")}
|
||||||
|
context_object_name = "qrcode"
|
||||||
|
slug_field = "qr_code_number"
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
qrcode = kwargs["slug"]
|
||||||
|
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
|
||||||
|
return super().get(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs))
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
qr_code_number = self.kwargs['slug']
|
||||||
|
qrcode = self.model.objects.get(qr_code_number=qr_code_number)
|
||||||
|
|
||||||
|
model = qrcode.food_container.polymorphic_ctype.model
|
||||||
|
|
||||||
|
if model == "basicfood":
|
||||||
|
context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood")
|
||||||
|
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood")
|
||||||
|
if model == "transformedfood":
|
||||||
|
context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||||
|
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood")
|
||||||
|
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
A view to add a tranformed food
|
||||||
|
"""
|
||||||
|
model = TransformedFood
|
||||||
|
template_name = 'food/transformedfood_form.html'
|
||||||
|
form_class = TransformedFoodForms
|
||||||
|
extra_context = {"title": _("Add a new meal")}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
transformed_food_form = TransformedFoodForms(data=self.request.POST)
|
||||||
|
if not transformed_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# Save the aliment and allergens associated
|
||||||
|
transformed_food = form.save(commit=False)
|
||||||
|
transformed_food.expiry_date = transformed_food.creation_date
|
||||||
|
transformed_food.is_active = True
|
||||||
|
transformed_food.is_ready = False
|
||||||
|
transformed_food.was_eaten = False
|
||||||
|
transformed_food._force_save = True
|
||||||
|
transformed_food.save()
|
||||||
|
transformed_food.refresh_from_db()
|
||||||
|
ans = super().form_valid(form)
|
||||||
|
transformed_food.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
# We choose a club which may work or BDE else
|
||||||
|
owner_id = 1
|
||||||
|
for membership in self.request.user.memberships.all():
|
||||||
|
club_id = membership.club.id
|
||||||
|
food = TransformedFood(name="",
|
||||||
|
creation_date=timezone.now(),
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
owner_id=club_id)
|
||||||
|
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
|
||||||
|
owner_id = club_id
|
||||||
|
break
|
||||||
|
|
||||||
|
return TransformedFood(
|
||||||
|
name="",
|
||||||
|
owner_id=owner_id,
|
||||||
|
creation_date=timezone.now(),
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Some field are hidden on create
|
||||||
|
form = context['form']
|
||||||
|
form.fields['is_active'].widget = HiddenInput()
|
||||||
|
form.fields['is_ready'].widget = HiddenInput()
|
||||||
|
form.fields['was_eaten'].widget = HiddenInput()
|
||||||
|
form.fields['shelf_life'].widget = HiddenInput()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
A view to update transformed product
|
||||||
|
"""
|
||||||
|
model = TransformedFood
|
||||||
|
template_name = 'food/transformedfood_form.html'
|
||||||
|
form_class = TransformedFoodForms
|
||||||
|
extra_context = {'title': _('Update a meal')}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
transformedfood_form = TransformedFoodForms(data=self.request.POST)
|
||||||
|
if not transformedfood_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
ans = super().form_valid(form)
|
||||||
|
form.instance.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||||
|
"""
|
||||||
|
Displays ready TransformedFood
|
||||||
|
"""
|
||||||
|
model = TransformedFood
|
||||||
|
tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable]
|
||||||
|
extra_context = {"title": _("Transformed food")}
|
||||||
|
|
||||||
|
def get_queryset(self, **kwargs):
|
||||||
|
return super().get_queryset(**kwargs).distinct()
|
||||||
|
|
||||||
|
def get_tables(self):
|
||||||
|
tables = super().get_tables()
|
||||||
|
|
||||||
|
tables[0].prefix = "all-"
|
||||||
|
tables[1].prefix = "open-"
|
||||||
|
tables[2].prefix = "served-"
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
# first table = all transformed food, second table = free, third = served
|
||||||
|
return [
|
||||||
|
self.get_queryset().order_by("-creation_date"),
|
||||||
|
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now())
|
||||||
|
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
|
||||||
|
.distinct()
|
||||||
|
.order_by("-creation_date"),
|
||||||
|
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now())
|
||||||
|
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
|
||||||
|
.distinct()
|
||||||
|
.order_by("-creation_date")
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# We choose a club which should work
|
||||||
|
for membership in self.request.user.memberships.all():
|
||||||
|
club_id = membership.club.id
|
||||||
|
food = TransformedFood(
|
||||||
|
name="",
|
||||||
|
owner_id=club_id,
|
||||||
|
creation_date=timezone.now(),
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
)
|
||||||
|
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
|
||||||
|
context['can_create_meal'] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
tables = context["tables"]
|
||||||
|
for name, table in zip(["table", "open", "served"], tables):
|
||||||
|
context[name] = table
|
||||||
|
return context
|
@ -2,7 +2,8 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
|
from api.filters import RegexSafeSearchFilter
|
||||||
from api.viewsets import ReadProtectedModelViewSet
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
|
from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
|
||||||
@ -17,7 +18,7 @@ class ProfileViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Profile.objects.order_by('id')
|
queryset = Profile.objects.order_by('id')
|
||||||
serializer_class = ProfileSerializer
|
serializer_class = ProfileSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email',
|
filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email',
|
||||||
'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section",
|
'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section",
|
||||||
'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
|
'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
|
||||||
@ -34,7 +35,7 @@ class ClubViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Club.objects.order_by('id')
|
queryset = Club.objects.order_by('id')
|
||||||
serializer_class = ClubSerializer
|
serializer_class = ClubSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
|
filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
|
||||||
'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
|
'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
|
||||||
'membership_duration', 'membership_start', 'membership_end', ]
|
'membership_duration', 'membership_start', 'membership_end', ]
|
||||||
@ -49,7 +50,7 @@ class MembershipViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Membership.objects.order_by('id')
|
queryset = Membership.objects.order_by('id')
|
||||||
serializer_class = MembershipSerializer
|
serializer_class = MembershipSerializer
|
||||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name',
|
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name',
|
||||||
'user__username', 'user__last_name', 'user__first_name', 'user__email',
|
'user__username', 'user__last_name', 'user__first_name', 'user__email',
|
||||||
'user__note__alias__name', 'user__note__alias__normalized_name',
|
'user__note__alias__name', 'user__note__alias__normalized_name',
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from PIL import Image, ImageSequence
|
from bootstrap_datepicker_plus.widgets import DatePickerInput
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.forms import AuthenticationForm
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
@ -13,8 +13,9 @@ from django.forms import CheckboxSelectMultiple
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from note.models import NoteSpecial, Alias
|
from note.models import NoteSpecial, Alias
|
||||||
from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput
|
from note_kfet.inputs import Autocomplete, AmountInput
|
||||||
from permission.models import PermissionMask, Role
|
from permission.models import PermissionMask, Role
|
||||||
|
from PIL import Image, ImageSequence
|
||||||
|
|
||||||
from .models import Profile, Club, Membership
|
from .models import Profile, Club, Membership
|
||||||
|
|
||||||
@ -32,7 +33,7 @@ class UserForm(forms.ModelForm):
|
|||||||
# Django usernames can only contain letters, numbers, @, ., +, - and _.
|
# Django usernames can only contain letters, numbers, @, ., +, - and _.
|
||||||
# We want to allow users to have uncommon and unpractical usernames:
|
# We want to allow users to have uncommon and unpractical usernames:
|
||||||
# That is their problem, and we have normalized aliases for us.
|
# That is their problem, and we have normalized aliases for us.
|
||||||
return super()._get_validation_exclusions() + ["username"]
|
return super()._get_validation_exclusions() | {"username"}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
18
apps/member/migrations/0013_auto_20240801_1436.py
Normal file
18
apps/member/migrations/0013_auto_20240801_1436.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-08-01 12:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('member', '0012_club_add_registration_form'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='profile',
|
||||||
|
name='promotion',
|
||||||
|
field=models.PositiveSmallIntegerField(default=2024, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
|
||||||
|
),
|
||||||
|
]
|
@ -295,7 +295,14 @@ class Club(models.Model):
|
|||||||
|
|
||||||
today = datetime.date.today()
|
today = datetime.date.today()
|
||||||
|
|
||||||
while (today - self.membership_start).days >= 365:
|
# Avoid any problems on February 29
|
||||||
|
if self.membership_start.month == 2 and self.membership_start.day == 29:
|
||||||
|
self.membership_start -= datetime.timedelta(days=1)
|
||||||
|
if self.membership_end.month == 2 and self.membership_end.day == 29:
|
||||||
|
self.membership_end += datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
while today >= datetime.date(self.membership_start.year + 1,
|
||||||
|
self.membership_start.month, self.membership_start.day):
|
||||||
if self.membership_start:
|
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)
|
||||||
|
@ -42,12 +42,12 @@ class UserTable(tables.Table):
|
|||||||
"""
|
"""
|
||||||
alias = tables.Column()
|
alias = tables.Column()
|
||||||
|
|
||||||
section = tables.Column(accessor='profile__section')
|
section = tables.Column(accessor='profile__section', orderable=False)
|
||||||
|
|
||||||
# Override the column to let replace the URL
|
# Override the column to let replace the URL
|
||||||
email = tables.EmailColumn(linkify=lambda record: "mailto:{}".format(record.email))
|
email = tables.EmailColumn(linkify=lambda record: "mailto:{}".format(record.email))
|
||||||
|
|
||||||
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
|
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"), orderable=False)
|
||||||
|
|
||||||
def render_email(self, record, value):
|
def render_email(self, record, value):
|
||||||
# Replace the email by a dash if the user can't see the profile detail
|
# Replace the email by a dash if the user can't see the profile detail
|
||||||
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="card-body">
|
<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...">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<label class="form-check-label" for="only_active">
|
<label class="form-check-label" for="only_active">
|
||||||
<input type="checkbox" class="checkboxinput form-check-input" id="only_active"
|
<input type="checkbox" class="checkboxinput form-check-input" id="only_active"
|
||||||
@ -66,4 +66,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
roles_obj.change(reloadTable);
|
roles_obj.change(reloadTable);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -16,8 +16,9 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, UpdateView, TemplateView
|
from django.views.generic import DetailView, UpdateView, TemplateView
|
||||||
from django.views.generic.edit import FormMixin
|
from django.views.generic.edit import FormMixin
|
||||||
from django_tables2.views import SingleTableView
|
from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
from api.viewsets import is_regex
|
||||||
from note.models import Alias, NoteClub, NoteUser, Trust
|
from note.models import Alias, NoteClub, NoteUser, Trust
|
||||||
from note.models.transactions import Transaction, SpecialTransaction
|
from note.models.transactions import Transaction, SpecialTransaction
|
||||||
from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable
|
from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable
|
||||||
@ -219,16 +220,20 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
|||||||
if "search" in self.request.GET and self.request.GET["search"]:
|
if "search" in self.request.GET and self.request.GET["search"]:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
|
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
username__iregex="^" + pattern
|
Q(**{f"username{suffix}": prefix + pattern})
|
||||||
).union(
|
).union(
|
||||||
qs.filter(
|
qs.filter(
|
||||||
(Q(alias__iregex="^" + pattern)
|
(Q(**{f"alias{suffix}": prefix + pattern})
|
||||||
| Q(normalized_alias__iregex="^" + Alias.normalize(pattern))
|
| Q(**{f"normalized_alias{suffix}": prefix + Alias.normalize(pattern)})
|
||||||
| Q(last_name__iregex="^" + pattern)
|
| Q(**{f"last_name{suffix}": prefix + pattern})
|
||||||
| Q(first_name__iregex="^" + pattern)
|
| Q(**{f"first_name{suffix}": prefix + pattern})
|
||||||
| Q(email__istartswith=pattern))
|
| Q(email__istartswith=pattern))
|
||||||
& ~Q(username__iregex="^" + pattern)
|
& ~Q(**{f"username{suffix}": prefix + pattern})
|
||||||
), all=True)
|
), all=True)
|
||||||
else:
|
else:
|
||||||
qs = qs.none()
|
qs = qs.none()
|
||||||
@ -243,7 +248,7 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
View and manage user trust relationships
|
View and manage user trust relationships
|
||||||
"""
|
"""
|
||||||
@ -252,13 +257,25 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
context_object_name = 'user_object'
|
context_object_name = 'user_object'
|
||||||
extra_context = {"title": _("Note friendships")}
|
extra_context = {"title": _("Note friendships")}
|
||||||
|
|
||||||
|
tables = [
|
||||||
|
lambda data: TrustTable(data, prefix="trust-"),
|
||||||
|
lambda data: TrustedTable(data, prefix="trusted-"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
note = self.object.note
|
||||||
|
return [
|
||||||
|
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct(),
|
||||||
|
note.trusted.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct(),
|
||||||
|
]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
note = context['object'].note
|
|
||||||
context["trusting"] = TrustTable(
|
tables = context["tables"]
|
||||||
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
|
for name, table in zip(["trusting", "trusted_by"], tables):
|
||||||
context["trusted_by"] = TrustedTable(
|
context[name] = table
|
||||||
note.trusted.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
|
|
||||||
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
|
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
|
||||||
trusting=context["object"].note,
|
trusting=context["object"].note,
|
||||||
trusted=context["object"].note
|
trusted=context["object"].note
|
||||||
@ -277,7 +294,7 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
View and manage user aliases.
|
View and manage user aliases.
|
||||||
"""
|
"""
|
||||||
@ -286,12 +303,15 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
context_object_name = 'user_object'
|
context_object_name = 'user_object'
|
||||||
extra_context = {"title": _("Note aliases")}
|
extra_context = {"title": _("Note aliases")}
|
||||||
|
|
||||||
|
table_class = AliasTable
|
||||||
|
context_table_name = "aliases"
|
||||||
|
|
||||||
|
def get_table_data(self):
|
||||||
|
return self.object.note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct() \
|
||||||
|
.order_by('normalized_name')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
note = context['object'].note
|
|
||||||
context["aliases"] = AliasTable(
|
|
||||||
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(
|
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
|
||||||
note=context["object"].note,
|
note=context["object"].note,
|
||||||
name="",
|
name="",
|
||||||
@ -410,10 +430,15 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
|||||||
if "search" in self.request.GET:
|
if "search" in self.request.GET:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
|
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
|
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(name__iregex=pattern)
|
Q(**{f"name{suffix}": prefix + pattern})
|
||||||
| Q(note__alias__name__iregex=pattern)
|
| Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||||
| Q(note__alias__normalized_name__iregex=Alias.normalize(pattern))
|
| Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||||
)
|
)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
@ -510,7 +535,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Manage aliases of a club.
|
Manage aliases of a club.
|
||||||
"""
|
"""
|
||||||
@ -519,11 +544,16 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
context_object_name = 'club'
|
context_object_name = 'club'
|
||||||
extra_context = {"title": _("Note aliases")}
|
extra_context = {"title": _("Note aliases")}
|
||||||
|
|
||||||
|
table_class = AliasTable
|
||||||
|
context_table_name = "aliases"
|
||||||
|
|
||||||
|
def get_table_data(self):
|
||||||
|
return self.object.note.alias.filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
note = context['object'].note
|
|
||||||
context["aliases"] = AliasTable(note.alias.filter(
|
|
||||||
PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
|
|
||||||
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
|
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
|
||||||
note=context["object"].note,
|
note=context["object"].note,
|
||||||
name="",
|
name="",
|
||||||
@ -912,10 +942,15 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
|
|||||||
|
|
||||||
if 'search' in self.request.GET:
|
if 'search' in self.request.GET:
|
||||||
pattern = self.request.GET['search']
|
pattern = self.request.GET['search']
|
||||||
|
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(user__first_name__iregex='^' + pattern)
|
Q(**{f"user__first_name{suffix}": prefix + pattern})
|
||||||
| Q(user__last_name__iregex='^' + pattern)
|
| Q(**{f"user__last_name{suffix}": prefix + pattern})
|
||||||
| Q(user__note__alias__normalized_name__iregex='^' + Alias.normalize(pattern))
|
| Q(**{f"user__note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||||
)
|
)
|
||||||
|
|
||||||
only_active = "only_active" not in self.request.GET or self.request.GET["only_active"] != '0'
|
only_active = "only_active" not in self.request.GET or self.request.GET["only_active"] != '0'
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
import re
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework import viewsets
|
from rest_framework import status, viewsets
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from api.filters import RegexSafeSearchFilter
|
||||||
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
|
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet, \
|
||||||
|
is_regex
|
||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer, \
|
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer, \
|
||||||
@ -29,7 +29,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Note.objects.order_by('id')
|
queryset = Note.objects.order_by('id')
|
||||||
serializer_class = NotePolymorphicSerializer
|
serializer_class = NotePolymorphicSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter, OrderingFilter]
|
||||||
filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ]
|
filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ]
|
||||||
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model',
|
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model',
|
||||||
'$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email',
|
'$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email',
|
||||||
@ -48,10 +48,14 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
|
|||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
alias = self.request.query_params.get("alias", ".*")
|
alias = self.request.query_params.get("alias", ".*")
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(alias)
|
||||||
|
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||||
|
alias_prefix = '^' if valid_regex else ''
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(alias__name__iregex="^" + alias)
|
Q(**{f"alias__name{suffix}": alias_prefix + alias})
|
||||||
| Q(alias__normalized_name__iregex="^" + Alias.normalize(alias))
|
| Q(**{f"alias__normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
|
||||||
| Q(alias__normalized_name__iregex="^" + alias.lower())
|
| Q(**{f"alias__normalized_name{suffix}": alias_prefix + alias.lower()})
|
||||||
)
|
)
|
||||||
|
|
||||||
return queryset.order_by("id")
|
return queryset.order_by("id")
|
||||||
@ -65,7 +69,7 @@ class TrustViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Trust.objects
|
queryset = Trust.objects
|
||||||
serializer_class = TrustSerializer
|
serializer_class = TrustSerializer
|
||||||
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
|
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
|
||||||
search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
|
search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
|
||||||
'$trusted__alias__name', '$trusted__alias__normalized_name']
|
'$trusted__alias__name', '$trusted__alias__normalized_name']
|
||||||
filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user']
|
filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user']
|
||||||
@ -91,11 +95,11 @@ class AliasViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
|
||||||
then render it on /api/note/aliases/
|
then render it on /api/note/alias/
|
||||||
"""
|
"""
|
||||||
queryset = Alias.objects
|
queryset = Alias.objects
|
||||||
serializer_class = AliasSerializer
|
serializer_class = AliasSerializer
|
||||||
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
|
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
|
||||||
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
|
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
|
||||||
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
|
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
|
||||||
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
||||||
@ -126,18 +130,22 @@ class AliasViewSet(ReadProtectedModelViewSet):
|
|||||||
|
|
||||||
alias = self.request.query_params.get("alias", None)
|
alias = self.request.query_params.get("alias", None)
|
||||||
if alias:
|
if alias:
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(alias)
|
||||||
|
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||||
|
alias_prefix = '^' if valid_regex else ''
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
name__iregex="^" + alias
|
**{f"name{suffix}": alias_prefix + alias}
|
||||||
).union(
|
).union(
|
||||||
queryset.filter(
|
queryset.filter(
|
||||||
Q(normalized_name__iregex="^" + Alias.normalize(alias))
|
Q(**{f"normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
|
||||||
& ~Q(name__iregex="^" + alias)
|
& ~Q(**{f"name{suffix}": alias_prefix + alias})
|
||||||
),
|
),
|
||||||
all=True).union(
|
all=True).union(
|
||||||
queryset.filter(
|
queryset.filter(
|
||||||
Q(normalized_name__iregex="^" + alias.lower())
|
Q(**{f"normalized_name{suffix}": "^" + alias.lower()})
|
||||||
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
|
& ~Q(**{f"normalized_name{suffix}": "^" + Alias.normalize(alias)})
|
||||||
& ~Q(name__iregex="^" + alias)
|
& ~Q(**{f"name{suffix}": "^" + alias})
|
||||||
),
|
),
|
||||||
all=True)
|
all=True)
|
||||||
|
|
||||||
@ -147,7 +155,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
|
|||||||
class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
|
class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
|
||||||
queryset = Alias.objects
|
queryset = Alias.objects
|
||||||
serializer_class = ConsumerSerializer
|
serializer_class = ConsumerSerializer
|
||||||
filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
|
filter_backends = [RegexSafeSearchFilter, OrderingFilter, DjangoFilterBackend]
|
||||||
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
|
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
|
||||||
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
|
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
|
||||||
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
|
||||||
@ -166,11 +174,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
|
|||||||
|
|
||||||
alias = self.request.query_params.get("alias", None)
|
alias = self.request.query_params.get("alias", None)
|
||||||
# Check if this is a valid regex. If not, we won't check regex
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
try:
|
valid_regex = is_regex(alias)
|
||||||
re.compile(alias)
|
|
||||||
valid_regex = True
|
|
||||||
except (re.error, TypeError):
|
|
||||||
valid_regex = False
|
|
||||||
suffix = '__iregex' if valid_regex else '__istartswith'
|
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||||
alias_prefix = '^' if valid_regex else ''
|
alias_prefix = '^' if valid_regex else ''
|
||||||
queryset = queryset.prefetch_related('note')
|
queryset = queryset.prefetch_related('note')
|
||||||
@ -179,19 +183,10 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
|
|||||||
# We match first an alias if it is matched without normalization,
|
# We match first an alias if it is matched without normalization,
|
||||||
# then if the normalized pattern matches a normalized alias.
|
# then if the normalized pattern matches a normalized alias.
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
**{f'name{suffix}': alias_prefix + alias}
|
Q(**{f'name{suffix}': alias_prefix + alias})
|
||||||
).union(
|
| Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
|
||||||
queryset.filter(
|
| 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).union(
|
|
||||||
queryset.filter(
|
|
||||||
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)
|
|
||||||
|
|
||||||
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
|
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
|
||||||
else queryset.order_by("name")
|
else queryset.order_by("name")
|
||||||
@ -207,7 +202,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = TemplateCategory.objects.order_by('name')
|
queryset = TemplateCategory.objects.order_by('name')
|
||||||
serializer_class = TemplateCategorySerializer
|
serializer_class = TemplateCategorySerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'templates', 'templates__name']
|
filterset_fields = ['name', 'templates', 'templates__name']
|
||||||
search_fields = ['$name', '$templates__name', ]
|
search_fields = ['$name', '$templates__name', ]
|
||||||
|
|
||||||
@ -220,7 +215,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = TransactionTemplate.objects.order_by('name')
|
queryset = TransactionTemplate.objects.order_by('name')
|
||||||
serializer_class = TransactionTemplateSerializer
|
serializer_class = TransactionTemplateSerializer
|
||||||
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
|
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
|
||||||
filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ]
|
filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ]
|
||||||
search_fields = ['$name', '$category__name', ]
|
search_fields = ['$name', '$category__name', ]
|
||||||
ordering_fields = ['amount', ]
|
ordering_fields = ['amount', ]
|
||||||
@ -234,7 +229,7 @@ class TransactionViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Transaction.objects.order_by('-created_at')
|
queryset = Transaction.objects.order_by('-created_at')
|
||||||
serializer_class = TransactionPolymorphicSerializer
|
serializer_class = TransactionPolymorphicSerializer
|
||||||
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
|
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
|
||||||
filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name',
|
filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name',
|
||||||
'destination', 'destination_alias', 'destination__alias__name',
|
'destination', 'destination_alias', 'destination__alias__name',
|
||||||
'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount',
|
'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount',
|
||||||
|
@ -2,12 +2,13 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.forms import CheckboxSelectMultiple
|
from django.forms import CheckboxSelectMultiple
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from note_kfet.inputs import Autocomplete, AmountInput, DateTimePickerInput
|
from note_kfet.inputs import Autocomplete, AmountInput
|
||||||
|
|
||||||
from .models import TransactionTemplate, NoteClub, Alias
|
from .models import TransactionTemplate, NoteClub, Alias
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ def create_special_notes(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('note', '0001_initial'),
|
('note', '0001_initial'),
|
||||||
|
('logs', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-08-28 08:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('note', '0006_trust'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='note',
|
||||||
|
name='polymorphic_ctype',
|
||||||
|
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transaction',
|
||||||
|
name='polymorphic_ctype',
|
||||||
|
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
|
||||||
|
),
|
||||||
|
]
|
@ -260,11 +260,13 @@ class ButtonTable(tables.Table):
|
|||||||
text=_('edit'),
|
text=_('edit'),
|
||||||
accessor='pk',
|
accessor='pk',
|
||||||
verbose_name=_("Edit"),
|
verbose_name=_("Edit"),
|
||||||
|
orderable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
hideshow = tables.Column(
|
hideshow = tables.Column(
|
||||||
verbose_name=_("Hide/Show"),
|
verbose_name=_("Hide/Show"),
|
||||||
accessor="pk",
|
accessor="pk",
|
||||||
|
orderable=False,
|
||||||
attrs={
|
attrs={
|
||||||
'td': {
|
'td': {
|
||||||
'class': 'col-sm-1',
|
'class': 'col-sm-1',
|
||||||
@ -276,7 +278,8 @@ class ButtonTable(tables.Table):
|
|||||||
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
|
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
|
||||||
extra_context={"delete_trans": _('delete')},
|
extra_context={"delete_trans": _('delete')},
|
||||||
attrs={'td': {'class': 'col-sm-1'}},
|
attrs={'td': {'class': 'col-sm-1'}},
|
||||||
verbose_name=_("Delete"), )
|
verbose_name=_("Delete"),
|
||||||
|
orderable=False, )
|
||||||
|
|
||||||
def render_amount(self, value):
|
def render_amount(self, value):
|
||||||
return pretty_money(value)
|
return pretty_money(value)
|
||||||
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
name="{{ widget.name }}"
|
name="{{ widget.name }}"
|
||||||
{# Other attributes are loaded #}
|
{# Other attributes are loaded #}
|
||||||
{% for name, value in widget.attrs.items %}
|
{% for name, value in widget.attrs.items %}
|
||||||
{% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
|
{% if value is not False %}{{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}
|
||||||
{% endfor %}>
|
{% endfor %}>
|
||||||
<div class="input-group-append">
|
<div class="input-group-append">
|
||||||
<span class="input-group-text">€</span>
|
<span class="input-group-text">€</span>
|
||||||
|
@ -13,6 +13,7 @@ from django.views.generic import CreateView, UpdateView, DetailView
|
|||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django_tables2 import SingleTableView
|
from django_tables2 import SingleTableView
|
||||||
from activity.models import Entry
|
from activity.models import Entry
|
||||||
|
from api.viewsets import is_regex
|
||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
from permission.views import ProtectQuerysetMixin
|
from permission.views import ProtectQuerysetMixin
|
||||||
from note_kfet.inputs import AmountInput
|
from note_kfet.inputs import AmountInput
|
||||||
@ -89,11 +90,15 @@ class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, Sing
|
|||||||
qs = super().get_queryset().distinct()
|
qs = super().get_queryset().distinct()
|
||||||
if "search" in self.request.GET:
|
if "search" in self.request.GET:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
|
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = "__iregex" if valid_regex else "__icontains"
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(name__iregex=pattern)
|
Q(**{f"name{suffix}": pattern})
|
||||||
| Q(destination__club__name__iregex=pattern)
|
| Q(**{f"destination__club__name{suffix}": pattern})
|
||||||
| Q(category__name__iregex=pattern)
|
| Q(**{f"category__name{suffix}": pattern})
|
||||||
| Q(description__iregex=pattern)
|
| Q(**{f"description{suffix}": pattern})
|
||||||
)
|
)
|
||||||
|
|
||||||
qs = qs.order_by('-display', 'category__name', 'destination__club__name', 'name')
|
qs = qs.order_by('-display', 'category__name', 'destination__club__name', 'name')
|
||||||
@ -223,7 +228,10 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView
|
|||||||
if "type" in data and data["type"]:
|
if "type" in data and data["type"]:
|
||||||
transactions = transactions.filter(polymorphic_ctype__in=data["type"])
|
transactions = transactions.filter(polymorphic_ctype__in=data["type"])
|
||||||
if "reason" in data and data["reason"]:
|
if "reason" in data and data["reason"]:
|
||||||
transactions = transactions.filter(reason__iregex=data["reason"])
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(data["reason"])
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
transactions = transactions.filter(Q(**{f"reason{suffix}": data["reason"]}))
|
||||||
if "valid" in data and data["valid"]:
|
if "valid" in data and data["valid"]:
|
||||||
transactions = transactions.filter(valid=data["valid"])
|
transactions = transactions.filter(valid=data["valid"])
|
||||||
if "amount_gte" in data and data["amount_gte"]:
|
if "amount_gte" in data and data["amount_gte"]:
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from api.viewsets import ReadOnlyProtectedModelViewSet
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.filters import SearchFilter
|
from api.filters import RegexSafeSearchFilter
|
||||||
|
from api.viewsets import ReadOnlyProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import PermissionSerializer, RoleSerializer
|
from .serializers import PermissionSerializer, RoleSerializer
|
||||||
from ..models import Permission, Role
|
from ..models import Permission, Role
|
||||||
@ -17,9 +17,9 @@ class PermissionViewSet(ReadOnlyProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Permission.objects.order_by('id')
|
queryset = Permission.objects.order_by('id')
|
||||||
serializer_class = PermissionSerializer
|
serializer_class = PermissionSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['model', 'type', 'query', 'mask', 'field', 'permanent', ]
|
filterset_fields = ['model', 'type', 'query', 'mask', 'field', 'permanent', ]
|
||||||
search_fields = ['$model__name', '$query', '$description', ]
|
search_fields = ['$model__model', '$query', '$description', ]
|
||||||
|
|
||||||
|
|
||||||
class RoleViewSet(ReadOnlyProtectedModelViewSet):
|
class RoleViewSet(ReadOnlyProtectedModelViewSet):
|
||||||
@ -30,6 +30,6 @@ class RoleViewSet(ReadOnlyProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Role.objects.order_by('id')
|
queryset = Role.objects.order_by('id')
|
||||||
serializer_class = RoleSerializer
|
serializer_class = RoleSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'permissions', 'for_club', 'memberships__user', ]
|
filterset_fields = ['name', 'permissions', 'for_club', 'memberships__user', ]
|
||||||
search_fields = ['$name', '$for_club__name', ]
|
search_fields = ['$name', '$for_club__name', ]
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -135,18 +135,18 @@ class Permission(models.Model):
|
|||||||
|
|
||||||
# A json encoded Q object with the following grammar
|
# A json encoded Q object with the following grammar
|
||||||
# query -> [] | {} (the empty query representing all objects)
|
# query -> [] | {} (the empty query representing all objects)
|
||||||
# query -> ["AND", query, …] AND multiple queries
|
# query -> ["AND", query, ...] AND multiple queries
|
||||||
# | ["OR", query, …] OR multiple queries
|
# | ["OR", query, ...] OR multiple queries
|
||||||
# | ["NOT", query] Opposite of query
|
# | ["NOT", query] Opposite of query
|
||||||
# query -> {key: value, …} A list of fields and values of a Q object
|
# query -> {key: value, ...} A list of fields and values of a Q object
|
||||||
# key -> string A field name
|
# key -> string A field name
|
||||||
# value -> int | string | bool | null Literal values
|
# value -> int | string | bool | null Literal values
|
||||||
# | [parameter, …] A parameter. See compute_param for more details.
|
# | [parameter, ...] A parameter. See compute_param for more details.
|
||||||
# | {"F": oper} An F object
|
# | {"F": oper} An F object
|
||||||
# oper -> [string, …] A parameter. See compute_param for more details.
|
# oper -> [string, ...] A parameter. See compute_param for more details.
|
||||||
# | ["ADD", oper, …] Sum multiple F objects or literal
|
# | ["ADD", oper, ...] Sum multiple F objects or literal
|
||||||
# | ["SUB", oper, oper] Substract two F objects or literal
|
# | ["SUB", oper, oper] Substract two F objects or literal
|
||||||
# | ["MUL", oper, …] Multiply F objects or literals
|
# | ["MUL", oper, ...] Multiply F objects or literals
|
||||||
# | int | string | bool | null Literal values
|
# | int | string | bool | null Literal values
|
||||||
# | ["F", string] A field
|
# | ["F", string] A field
|
||||||
#
|
#
|
||||||
|
@ -35,6 +35,8 @@ class PermissionScopes(BaseScopes):
|
|||||||
|
|
||||||
|
|
||||||
class PermissionOAuth2Validator(OAuth2Validator):
|
class PermissionOAuth2Validator(OAuth2Validator):
|
||||||
|
oidc_claim_scope = None # fix breaking change of django-oauth-toolkit 2.0.0
|
||||||
|
|
||||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
User can request as many scope as he wants, including invalid scopes,
|
User can request as many scope as he wants, including invalid scopes,
|
||||||
|
@ -12,6 +12,7 @@ from django.forms import HiddenInput
|
|||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import UpdateView, TemplateView, CreateView
|
from django.views.generic import UpdateView, TemplateView, CreateView
|
||||||
|
from django_tables2 import MultiTableMixin
|
||||||
from member.models import Membership
|
from member.models import Membership
|
||||||
|
|
||||||
from .backends import PermissionBackend
|
from .backends import PermissionBackend
|
||||||
@ -35,11 +36,9 @@ class ProtectQuerysetMixin:
|
|||||||
try:
|
try:
|
||||||
return super().get_object(queryset)
|
return super().get_object(queryset)
|
||||||
except Http404 as e:
|
except Http404 as e:
|
||||||
try:
|
if self.get_queryset(filter_permissions=False).count() == self.get_queryset().count():
|
||||||
super().get_object(self.get_queryset(filter_permissions=False))
|
|
||||||
raise PermissionDenied()
|
|
||||||
except Http404:
|
|
||||||
raise e
|
raise e
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def get_form(self, form_class=None):
|
def get_form(self, form_class=None):
|
||||||
form = super().get_form(form_class)
|
form = super().get_form(form_class)
|
||||||
@ -107,10 +106,31 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView):
|
|||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class RightsView(TemplateView):
|
class RightsView(MultiTableMixin, TemplateView):
|
||||||
template_name = "permission/all_rights.html"
|
template_name = "permission/all_rights.html"
|
||||||
extra_context = {"title": _("Rights")}
|
extra_context = {"title": _("Rights")}
|
||||||
|
|
||||||
|
tables = [
|
||||||
|
lambda data: RightsTable(data, prefix="clubs-"),
|
||||||
|
lambda data: SuperuserTable(data, prefix="superusers-"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
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⋅e BDE")
|
||||||
|
| Q(name="Adhérent⋅e Kfet")
|
||||||
|
| Q(name="Membre de club")
|
||||||
|
| Q(name="Bureau de club"))
|
||||||
|
& Q(weirole__isnull=True))))\
|
||||||
|
.order_by("club__name", "user__last_name")\
|
||||||
|
.distinct().all()
|
||||||
|
return [
|
||||||
|
special_memberships,
|
||||||
|
User.objects.filter(is_superuser=True).order_by("last_name"),
|
||||||
|
]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
@ -128,19 +148,9 @@ class RightsView(TemplateView):
|
|||||||
role.clubs = [membership.club for membership in active_memberships if role in membership.roles.all()]
|
role.clubs = [membership.club for membership in active_memberships if role in membership.roles.all()]
|
||||||
|
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
special_memberships = Membership.objects.filter(
|
tables = context["tables"]
|
||||||
date_start__lte=date.today(),
|
for name, table in zip(["special_memberships_table", "superusers"], tables):
|
||||||
date_end__gte=date.today(),
|
context[name] = table
|
||||||
).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))))\
|
|
||||||
.order_by("club__name", "user__last_name")\
|
|
||||||
.distinct().all()
|
|
||||||
context["special_memberships_table"] = RightsTable(special_memberships, prefix="clubs-")
|
|
||||||
context["superusers"] = SuperuserTable(User.objects.filter(is_superuser=True).order_by("last_name").all(),
|
|
||||||
prefix="superusers-")
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ from django.views import View
|
|||||||
from django.views.generic import CreateView, TemplateView, DetailView
|
from django.views.generic import CreateView, TemplateView, DetailView
|
||||||
from django.views.generic.edit import FormMixin
|
from django.views.generic.edit import FormMixin
|
||||||
from django_tables2 import SingleTableView
|
from django_tables2 import SingleTableView
|
||||||
|
from api.viewsets import is_regex
|
||||||
from member.forms import ProfileForm
|
from member.forms import ProfileForm
|
||||||
from member.models import Membership, Club
|
from member.models import Membership, Club
|
||||||
from note.models import SpecialTransaction, Alias
|
from note.models import SpecialTransaction, Alias
|
||||||
@ -192,11 +193,16 @@ class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
|
|||||||
if "search" in self.request.GET and self.request.GET["search"]:
|
if "search" in self.request.GET and self.request.GET["search"]:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
|
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix_username = "__iregex" if valid_regex else "__icontains"
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(first_name__iregex=pattern)
|
Q(**{f"first_name{suffix}": pattern})
|
||||||
| Q(last_name__iregex=pattern)
|
| Q(**{f"last_name{suffix}": pattern})
|
||||||
| Q(profile__section__iregex=pattern)
|
| Q(**{f"profile__section{suffix}": pattern})
|
||||||
| Q(username__iregex="^" + pattern)
|
| Q(**{f"username{suffix_username}": prefix + pattern})
|
||||||
)
|
)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
@ -294,9 +300,13 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
|
|||||||
# join_bde = True
|
# join_bde = True
|
||||||
# join_kfet = True
|
# join_kfet = True
|
||||||
|
|
||||||
if not join_bde:
|
if not (join_bde or any(b for _, b in join_clubs)):
|
||||||
# This software belongs to the BDE.
|
# This software belongs to the BDE.
|
||||||
form.add_error('join_bde', _("You must join the BDE."))
|
form.add_error('join_bde', _("You must join a club."))
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
if join_kfet and not join_bde:
|
||||||
|
form.add_error('join_bde', _("You must also join the parent club BDE."))
|
||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
||||||
|
|
||||||
# Calculate required registration fee
|
# Calculate required registration fee
|
||||||
|
Submodule apps/scripts updated: 472c9c33ce...f580f9b9e9
@ -2,7 +2,7 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.filters import SearchFilter
|
from api.filters import RegexSafeSearchFilter
|
||||||
from api.viewsets import ReadProtectedModelViewSet
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import InvoiceSerializer, ProductSerializer, RemittanceTypeSerializer, RemittanceSerializer, \
|
from .serializers import InvoiceSerializer, ProductSerializer, RemittanceTypeSerializer, RemittanceSerializer, \
|
||||||
@ -18,7 +18,7 @@ class InvoiceViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Invoice.objects.order_by('id')
|
queryset = Invoice.objects.order_by('id')
|
||||||
serializer_class = InvoiceSerializer
|
serializer_class = InvoiceSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['bde', 'object', 'description', 'name', 'address', 'date', 'acquitted', 'locked', ]
|
filterset_fields = ['bde', 'object', 'description', 'name', 'address', 'date', 'acquitted', 'locked', ]
|
||||||
search_fields = ['$object', '$description', '$name', '$address', ]
|
search_fields = ['$object', '$description', '$name', '$address', ]
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ class ProductViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Product.objects.order_by('invoice_id', 'id')
|
queryset = Product.objects.order_by('invoice_id', 'id')
|
||||||
serializer_class = ProductSerializer
|
serializer_class = ProductSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['invoice', 'designation', 'quantity', 'amount', ]
|
filterset_fields = ['invoice', 'designation', 'quantity', 'amount', ]
|
||||||
search_fields = ['$designation', '$invoice__object', ]
|
search_fields = ['$designation', '$invoice__object', ]
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = RemittanceType.objects.order_by('id')
|
queryset = RemittanceType.objects.order_by('id')
|
||||||
serializer_class = RemittanceTypeSerializer
|
serializer_class = RemittanceTypeSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['note', ]
|
filterset_fields = ['note', ]
|
||||||
search_fields = ['$note__special_type', ]
|
search_fields = ['$note__special_type', ]
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ class RemittanceViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Remittance.objects.order_by('id')
|
queryset = Remittance.objects.order_by('id')
|
||||||
serializer_class = RemittanceSerializer
|
serializer_class = RemittanceSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'transaction_proxies__transaction', ]
|
filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'transaction_proxies__transaction', ]
|
||||||
search_fields = ['$remittance_type__note__special_type', '$comment', ]
|
search_fields = ['$remittance_type__note__special_type', '$comment', ]
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ class SogeCreditViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = SogeCredit.objects.order_by('id')
|
queryset = SogeCredit.objects.order_by('id')
|
||||||
serializer_class = SogeCreditSerializer
|
serializer_class = SogeCreditSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['user', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name',
|
filterset_fields = ['user', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name',
|
||||||
'user__note__alias__normalized_name', 'transactions', 'credit_transaction', ]
|
'user__note__alias__normalized_name', 'transactions', 'credit_transaction', ]
|
||||||
search_fields = ['$user__last_name', '$user__first_name', '$user__email', '$user__note__alias__name',
|
search_fields = ['$user__last_name', '$user__first_name', '$user__email', '$user__note__alias__name',
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-08-28 08:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('note', '0007_alter_note_polymorphic_ctype_and_more'),
|
||||||
|
('treasury', '0008_auto_20240322_0045'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sogecredit',
|
||||||
|
name='transactions',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='+', to='note.membershiptransaction', verbose_name='membership transactions'),
|
||||||
|
),
|
||||||
|
]
|
@ -37,6 +37,7 @@ class InvoiceTable(tables.Table):
|
|||||||
args=[A('id')],
|
args=[A('id')],
|
||||||
verbose_name=_("delete"),
|
verbose_name=_("delete"),
|
||||||
text=_("Delete"),
|
text=_("Delete"),
|
||||||
|
orderable=False,
|
||||||
attrs={
|
attrs={
|
||||||
'th': {
|
'th': {
|
||||||
'id': 'delete-membership-header'
|
'id': 'delete-membership-header'
|
||||||
@ -70,6 +71,7 @@ class RemittanceTable(tables.Table):
|
|||||||
verbose_name=_("View"),
|
verbose_name=_("View"),
|
||||||
args=[A("pk")],
|
args=[A("pk")],
|
||||||
text=_("View"),
|
text=_("View"),
|
||||||
|
orderable=False,
|
||||||
attrs={
|
attrs={
|
||||||
'a': {'class': 'btn btn-primary'}
|
'a': {'class': 'btn btn-primary'}
|
||||||
}, )
|
}, )
|
||||||
@ -97,6 +99,7 @@ class SpecialTransactionTable(tables.Table):
|
|||||||
verbose_name=_("Remittance"),
|
verbose_name=_("Remittance"),
|
||||||
args=[A("specialtransactionproxy__pk")],
|
args=[A("specialtransactionproxy__pk")],
|
||||||
text=_("Add"),
|
text=_("Add"),
|
||||||
|
orderable=False,
|
||||||
attrs={
|
attrs={
|
||||||
'a': {'class': 'btn btn-primary'}
|
'a': {'class': 'btn btn-primary'}
|
||||||
}, )
|
}, )
|
||||||
@ -105,6 +108,7 @@ class SpecialTransactionTable(tables.Table):
|
|||||||
verbose_name=_("Remittance"),
|
verbose_name=_("Remittance"),
|
||||||
args=[A("specialtransactionproxy__pk")],
|
args=[A("specialtransactionproxy__pk")],
|
||||||
text=_("Remove"),
|
text=_("Remove"),
|
||||||
|
orderable=False,
|
||||||
attrs={
|
attrs={
|
||||||
'a': {'class': 'btn btn-primary btn-danger'}
|
'a': {'class': 'btn btn-primary btn-danger'}
|
||||||
}, )
|
}, )
|
||||||
@ -130,10 +134,12 @@ class SogeCreditTable(tables.Table):
|
|||||||
|
|
||||||
amount = tables.Column(
|
amount = tables.Column(
|
||||||
verbose_name=_("Amount"),
|
verbose_name=_("Amount"),
|
||||||
|
orderable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
valid = tables.Column(
|
valid = tables.Column(
|
||||||
verbose_name=_("Valid"),
|
verbose_name=_("Valid"),
|
||||||
|
orderable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def render_amount(self, value):
|
def render_amount(self, value):
|
||||||
|
@ -109,7 +109,7 @@
|
|||||||
\renewcommand{\headrulewidth}{0pt}
|
\renewcommand{\headrulewidth}{0pt}
|
||||||
\cfoot{
|
\cfoot{
|
||||||
\small{\MonNom ~--~ \MonAdresseRue ~ \MonAdresseVille ~--~ Téléphone : +33(0)7 78 17 22 34\newline
|
\small{\MonNom ~--~ \MonAdresseRue ~ \MonAdresseVille ~--~ Téléphone : +33(0)7 78 17 22 34\newline
|
||||||
Site web : bde.ens-cachan.fr ~--~ E-mail : tresorerie.bde@lists.crans.org \newline Numéro SIRET : 399 485 838 00029
|
E-mail : tresorerie.bde@lists.crans.org ~--~ Numéro SIRET : 399 485 838 00029
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,7 +19,8 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.views.generic import UpdateView, DetailView
|
from django.views.generic import UpdateView, DetailView
|
||||||
from django.views.generic.base import View, TemplateView
|
from django.views.generic.base import View, TemplateView
|
||||||
from django.views.generic.edit import BaseFormView, DeleteView
|
from django.views.generic.edit import BaseFormView, DeleteView
|
||||||
from django_tables2 import SingleTableView
|
from django_tables2 import MultiTableMixin, SingleTableMixin, SingleTableView
|
||||||
|
from api.viewsets import is_regex
|
||||||
from note.models import SpecialTransaction, NoteSpecial, Alias
|
from note.models import SpecialTransaction, NoteSpecial, Alias
|
||||||
from note_kfet.settings.base import BASE_DIR
|
from note_kfet.settings.base import BASE_DIR
|
||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
@ -251,21 +252,26 @@ class RemittanceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context["table"] = RemittanceTable(
|
|
||||||
data=Remittance.objects.filter(
|
|
||||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all())
|
|
||||||
context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
|
context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class RemittanceListView(LoginRequiredMixin, TemplateView):
|
class RemittanceListView(LoginRequiredMixin, MultiTableMixin, TemplateView):
|
||||||
"""
|
"""
|
||||||
List existing Remittances
|
List existing Remittances
|
||||||
"""
|
"""
|
||||||
template_name = "treasury/remittance_list.html"
|
template_name = "treasury/remittance_list.html"
|
||||||
extra_context = {"title": _("Remittances list")}
|
extra_context = {"title": _("Remittances list")}
|
||||||
|
|
||||||
|
tables = [
|
||||||
|
lambda data: RemittanceTable(data, prefix="opened-remittances-"),
|
||||||
|
lambda data: RemittanceTable(data, prefix="closed-remittances-"),
|
||||||
|
lambda data: SpecialTransactionTable(data, prefix="no-remittance-", exclude=('remittance_remove', )),
|
||||||
|
lambda data: SpecialTransactionTable(data, prefix="with-remittance-", exclude=('remittance_add', )),
|
||||||
|
]
|
||||||
|
paginate_by = 10 # number of rows in tables
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
# Check that the user is authenticated
|
# Check that the user is authenticated
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
@ -275,49 +281,37 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
|
|||||||
raise PermissionDenied(_("You are not able to see the treasury interface."))
|
raise PermissionDenied(_("You are not able to see the treasury interface."))
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
return [
|
||||||
|
Remittance.objects.filter(closed=False).filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
|
||||||
|
Remittance.objects.filter(closed=True).filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
|
||||||
|
SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
|
||||||
|
specialtransactionproxy__remittance=None).filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
|
||||||
|
SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
|
||||||
|
specialtransactionproxy__remittance__closed=False).filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
|
||||||
|
]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
opened_remittances = RemittanceTable(
|
tables = context["tables"]
|
||||||
data=Remittance.objects.filter(closed=False).filter(
|
names = [
|
||||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
|
"opened_remittances",
|
||||||
prefix="opened-remittances-",
|
"closed_remittances",
|
||||||
)
|
"special_transactions_no_remittance",
|
||||||
opened_remittances.paginate(page=self.request.GET.get("opened-remittances-page", 1), per_page=10)
|
"special_transactions_with_remittance",
|
||||||
context["opened_remittances"] = opened_remittances
|
]
|
||||||
|
for name, table in zip(names, tables):
|
||||||
closed_remittances = RemittanceTable(
|
context[name] = table
|
||||||
data=Remittance.objects.filter(closed=True).filter(
|
|
||||||
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)
|
|
||||||
context["closed_remittances"] = closed_remittances
|
|
||||||
|
|
||||||
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, Remittance, "view")).all(),
|
|
||||||
exclude=('remittance_remove', ),
|
|
||||||
prefix="no-remittance-",
|
|
||||||
)
|
|
||||||
no_remittance_tr.paginate(page=self.request.GET.get("no-remittance-page", 1), per_page=10)
|
|
||||||
context["special_transactions_no_remittance"] = no_remittance_tr
|
|
||||||
|
|
||||||
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, Remittance, "view")).all(),
|
|
||||||
exclude=('remittance_add', ),
|
|
||||||
prefix="with-remittance-",
|
|
||||||
)
|
|
||||||
with_remittance_tr.paginate(page=self.request.GET.get("with-remittance-page", 1), per_page=10)
|
|
||||||
context["special_transactions_with_remittance"] = with_remittance_tr
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, UpdateView):
|
||||||
"""
|
"""
|
||||||
Update Remittance
|
Update Remittance
|
||||||
"""
|
"""
|
||||||
@ -325,19 +319,18 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView)
|
|||||||
form_class = RemittanceForm
|
form_class = RemittanceForm
|
||||||
extra_context = {"title": _("Update a remittance")}
|
extra_context = {"title": _("Update a remittance")}
|
||||||
|
|
||||||
|
table_class = SpecialTransactionTable
|
||||||
|
context_table_name = "special_transactions"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy('treasury:remittance_list')
|
return reverse_lazy('treasury:remittance_list')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_table_data(self):
|
||||||
context = super().get_context_data(**kwargs)
|
return SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Remittance, "view"))
|
||||||
|
|
||||||
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
|
def get_table_kwargs(self):
|
||||||
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all()
|
return {"exclude": ('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', )}
|
||||||
context["special_transactions"] = SpecialTransactionTable(
|
|
||||||
data=data,
|
|
||||||
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
class LinkTransactionToRemittanceView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
class LinkTransactionToRemittanceView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
@ -411,11 +404,16 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
|
|||||||
if "search" in self.request.GET:
|
if "search" in self.request.GET:
|
||||||
pattern = self.request.GET["search"]
|
pattern = self.request.GET["search"]
|
||||||
if pattern:
|
if pattern:
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix_alias = "__iregex" if valid_regex else "__icontains"
|
||||||
|
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(user__first_name__iregex=pattern)
|
Q(**{f"user__first_name{suffix}": pattern})
|
||||||
| Q(user__last_name__iregex=pattern)
|
| Q(**{f"user__last_name{suffix}": pattern})
|
||||||
| Q(user__note__alias__name__iregex="^" + pattern)
|
| Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
|
||||||
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
| Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
|
||||||
)
|
)
|
||||||
|
|
||||||
if "valid" not in self.request.GET or not self.request.GET["valid"]:
|
if "valid" not in self.request.GET or not self.request.GET["valid"]:
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
|
from api.filters import RegexSafeSearchFilter
|
||||||
from api.viewsets import ReadProtectedModelViewSet
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
|
||||||
from .serializers import WEIClubSerializer, BusSerializer, BusTeamSerializer, WEIRoleSerializer, \
|
from .serializers import WEIClubSerializer, BusSerializer, BusTeamSerializer, WEIRoleSerializer, \
|
||||||
@ -18,7 +19,7 @@ class WEIClubViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = WEIClub.objects.order_by('id')
|
queryset = WEIClub.objects.order_by('id')
|
||||||
serializer_class = WEIClubSerializer
|
serializer_class = WEIClubSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'year', 'date_start', 'date_end', 'email', 'note__alias__name',
|
filterset_fields = ['name', 'year', 'date_start', 'date_end', 'email', 'note__alias__name',
|
||||||
'note__alias__normalized_name', 'parent_club', 'parent_club__name', 'require_memberships',
|
'note__alias__normalized_name', 'parent_club', 'parent_club__name', 'require_memberships',
|
||||||
'membership_fee_paid', 'membership_fee_unpaid', 'membership_duration', 'membership_start',
|
'membership_fee_paid', 'membership_fee_unpaid', 'membership_duration', 'membership_start',
|
||||||
@ -34,7 +35,7 @@ class BusViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Bus.objects.order_by('id')
|
queryset = Bus.objects.order_by('id')
|
||||||
serializer_class = BusSerializer
|
serializer_class = BusSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'wei', 'description', ]
|
filterset_fields = ['name', 'wei', 'description', ]
|
||||||
search_fields = ['$name', '$wei__name', '$description', ]
|
search_fields = ['$name', '$wei__name', '$description', ]
|
||||||
|
|
||||||
@ -47,7 +48,7 @@ class BusTeamViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = BusTeam.objects.order_by('id')
|
queryset = BusTeam.objects.order_by('id')
|
||||||
serializer_class = BusTeamSerializer
|
serializer_class = BusTeamSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'bus', 'color', 'description', 'bus__wei', ]
|
filterset_fields = ['name', 'bus', 'color', 'description', 'bus__wei', ]
|
||||||
search_fields = ['$name', '$bus__name', '$bus__wei__name', '$description', ]
|
search_fields = ['$name', '$bus__name', '$bus__wei__name', '$description', ]
|
||||||
|
|
||||||
@ -60,7 +61,7 @@ class WEIRoleViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = WEIRole.objects.order_by('id')
|
queryset = WEIRole.objects.order_by('id')
|
||||||
serializer_class = WEIRoleSerializer
|
serializer_class = WEIRoleSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'permissions', 'memberships', ]
|
filterset_fields = ['name', 'permissions', 'memberships', ]
|
||||||
search_fields = ['$name', ]
|
search_fields = ['$name', ]
|
||||||
|
|
||||||
@ -73,7 +74,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = WEIRegistration.objects.order_by('id')
|
queryset = WEIRegistration.objects.order_by('id')
|
||||||
serializer_class = WEIRegistrationSerializer
|
serializer_class = WEIRegistrationSerializer
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email',
|
filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email',
|
||||||
'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name',
|
'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name',
|
||||||
'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender',
|
'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender',
|
||||||
@ -92,7 +93,7 @@ class WEIMembershipViewSet(ReadProtectedModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = WEIMembership.objects.order_by('id')
|
queryset = WEIMembership.objects.order_by('id')
|
||||||
serializer_class = WEIMembershipSerializer
|
serializer_class = WEIMembershipSerializer
|
||||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name',
|
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name',
|
||||||
'club__note__alias__normalized_name', 'user__username', 'user__last_name',
|
'club__note__alias__normalized_name', 'user__username', 'user__last_name',
|
||||||
'user__first_name', 'user__email', 'user__note__alias__name',
|
'user__first_name', 'user__email', 'user__note__alias__name',
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from bootstrap_datepicker_plus.widgets import DatePickerInput
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.forms import CheckboxSelectMultiple
|
from django.forms import CheckboxSelectMultiple
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from note.models import NoteSpecial, NoteUser
|
from note.models import NoteSpecial, NoteUser
|
||||||
from note_kfet.inputs import AmountInput, DatePickerInput, Autocomplete, ColorWidget
|
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
|
||||||
|
|
||||||
from ..models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership, WEIRole
|
from ..models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership, WEIRole
|
||||||
|
|
||||||
@ -80,6 +81,11 @@ class WEIChooseBusForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class WEIMembershipForm(forms.ModelForm):
|
class WEIMembershipForm(forms.ModelForm):
|
||||||
|
caution_check = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_("Caution check given"),
|
||||||
|
)
|
||||||
|
|
||||||
roles = forms.ModelMultipleChoiceField(
|
roles = forms.ModelMultipleChoiceField(
|
||||||
queryset=WEIRole.objects,
|
queryset=WEIRole.objects,
|
||||||
label=_("WEI Roles"),
|
label=_("WEI Roles"),
|
||||||
@ -148,6 +154,7 @@ class WEIMembership1AForm(WEIMembershipForm):
|
|||||||
"""
|
"""
|
||||||
Used to confirm registrations of first year members without choosing a bus now.
|
Used to confirm registrations of first year members without choosing a bus now.
|
||||||
"""
|
"""
|
||||||
|
caution_check = None
|
||||||
roles = None
|
roles = None
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
@ -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 .wei2023 import WEISurvey2023
|
from .wei2024 import WEISurvey2024
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||||
]
|
]
|
||||||
|
|
||||||
CurrentSurvey = WEISurvey2023
|
CurrentSurvey = WEISurvey2024
|
||||||
|
@ -82,7 +82,7 @@ WORDS = {
|
|||||||
5: "La quoi ?"
|
5: "La quoi ?"
|
||||||
}],
|
}],
|
||||||
"kokarde": ["Qu'est-ce que le mot Kokarde t'évoque ?", {
|
"kokarde": ["Qu'est-ce que le mot Kokarde t'évoque ?", {
|
||||||
1: "Vraiment pas mon truc les soirées…",
|
1: "Vraiment pas mon truc les soirées...",
|
||||||
2: "Bof, je viens pour manger et je repars aussitôt",
|
2: "Bof, je viens pour manger et je repars aussitôt",
|
||||||
3: "Je kiffe, good vibes",
|
3: "Je kiffe, good vibes",
|
||||||
4: "Perso, je ne m'arrêterai pas de danser sur la piste !",
|
4: "Perso, je ne m'arrêterai pas de danser sur la piste !",
|
||||||
@ -117,15 +117,15 @@ WORDS = {
|
|||||||
5: "Je pourrais en faire à n'importe qui. Pourquoi ne pas créer le club Câl[ENS] ?"
|
5: "Je pourrais en faire à n'importe qui. Pourquoi ne pas créer le club Câl[ENS] ?"
|
||||||
}],
|
}],
|
||||||
"vomi": ["Quel est ton rapport au vomi ?", {
|
"vomi": ["Quel est ton rapport au vomi ?", {
|
||||||
1: "C'est compliqué…",
|
1: "C'est compliqué...",
|
||||||
2: "Jamais je ne vomis mais je nettoie quand mes potes vomissent",
|
2: "Jamais je ne vomis mais je nettoie quand mes potes vomissent",
|
||||||
3: "Jamais je ne vomis et jamais je ne nettoie celui de quelqu'un d'autre",
|
3: "Jamais je ne vomis et jamais je ne nettoie celui de quelqu'un d'autre",
|
||||||
4: "Je vomis quelquefois, ça arrive, faites pas cette tête, mais je fins toujours par nettoyer !",
|
4: "Je vomis quelquefois, ça arrive, faites pas cette tête, mais je fins toujours par nettoyer !",
|
||||||
5: "Je vomis à chaque soirée et ce n'est jamais moi qui nettoie"
|
5: "Je vomis à chaque soirée et ce n'est jamais moi qui nettoie"
|
||||||
}],
|
}],
|
||||||
"kfet": ["Qu'est ce que la Kfet t'évoque ?", {
|
"kfet": ["Qu'est ce que la Kfet t'évoque ?", {
|
||||||
1: "La Kfet, quel lieu de dépravé⋅es sérieux…",
|
1: "La Kfet, quel lieu de dépravé⋅es sérieux...",
|
||||||
2: "C'est un endroit à l'hygiène plus que douteuse…",
|
2: "C'est un endroit à l'hygiène plus que douteuse...",
|
||||||
3: "Téma les prix des boissons et des snacks, c'est aberrant !",
|
3: "Téma les prix des boissons et des snacks, c'est aberrant !",
|
||||||
4: "En vrai, c'est cool, petit billard, petit canapé, chill !",
|
4: "En vrai, c'est cool, petit billard, petit canapé, chill !",
|
||||||
5: "Banger, j'y reste jusqu'à la fin de mes jours"
|
5: "Banger, j'y reste jusqu'à la fin de mes jours"
|
||||||
@ -147,7 +147,7 @@ WORDS = {
|
|||||||
"scolarite": ["Comment tu vois ton cursus à l'ENS ?", {
|
"scolarite": ["Comment tu vois ton cursus à l'ENS ?", {
|
||||||
1: "La tranquillité et le travail",
|
1: "La tranquillité et le travail",
|
||||||
2: "On va s'amuser tout en bossant",
|
2: "On va s'amuser tout en bossant",
|
||||||
3: "Ça va profiter et réviser au dernier moment pour les exams…",
|
3: "Ça va profiter et réviser au dernier moment pour les exams...",
|
||||||
4: "Nous festoierons sans songer aux conséquences",
|
4: "Nous festoierons sans songer aux conséquences",
|
||||||
5: "Je ne vois qu'une seule issue : la débauche"
|
5: "Je ne vois qu'une seule issue : la débauche"
|
||||||
}]
|
}]
|
||||||
|
381
apps/wei/forms/surveys/wei2024.py
Normal file
381
apps/wei/forms/surveys/wei2024.py
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
|
||||||
|
from ...models import WEIMembership
|
||||||
|
|
||||||
|
|
||||||
|
buses_descr = [
|
||||||
|
[
|
||||||
|
"Magi[Kar]p 🐙🎮🎲", "#ef5568", 1,
|
||||||
|
"""Vous l'aurez compris au nom du bus, l'ambiance est aux jeux et à la culture geek ! Ici, vous trouverez une ambiance
|
||||||
|
calme avec une bonne dose d'autodérision et de second degré. Que vous ayez besoin de beaucoup dormir pour tenir la soirée
|
||||||
|
du lendemain, ou que vous souhaitiez faire nuit blanche pour jouer toute la nuit, vous pouvez nous rejoindre. Votre voix
|
||||||
|
n'y survivra peut-être pas à force de chanter. PS : les meilleurs cocktails du WEI sont chez nous, à déguster, pas à
|
||||||
|
siphonner !""",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Va[car]me 🎷🍎🔊", "#fd7a28", 3,
|
||||||
|
"""Ici, c'est le bus du bruit. Si vous voulez réveiller les autres bus en musique, apprendre de merveilleuses
|
||||||
|
mélodies au kazoo tout le week-end, ou simplement profiter d'une bonne ambiance musicale, le BDA et la
|
||||||
|
F[ENS]foire sont là pour vous. Vous pourrez également goûter au célèbre cocktail de la fanfare, concocté
|
||||||
|
pour l'occasion par les tout nouveaux "meilleurs artisans v*********** de France" ! Alors que vous soyez artiste
|
||||||
|
dans l'âme ou que vous souhaitiez juste faire le plus grand Vacarme, rejoignez-nous !""",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"[Kar]aïbes 🏝️🏴☠️🥥", "#a5cfdd", 3,
|
||||||
|
"""Ahoy, explorateurs du WEI ! Le bus Karaibes t’invite à une traversée sous les tropiques, où l’ambiance est
|
||||||
|
toujours au beau fixe ! ☀️🍹 Ici, c’est soleil, rhum, et bonne humeur assurée : une atmosphère de vacances où
|
||||||
|
l’on se laisse porter par la chaleur humaine et la fête. Que tu sois un pirate en quête de sensations fortes ou
|
||||||
|
un amateur de chill avec un cocktail à la main, tu seras à ta place dans notre bus. Les soirées seront marquées
|
||||||
|
par des rythmes tropicaux qui te feront vibrer jusqu’à l’aube. Prêt à embarquer pour une aventure inoubliable
|
||||||
|
avec les meilleurs matelots du WEI ? On t’attend sur le pont du Karaibes pour lever l’ancre ensemble !""",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"[Kar]di [Bus] 🎙️💅", "#e46398", 2.5,
|
||||||
|
"""Bienvenue à bord du Kardi Bus, la seul, l’unique, l’inimitable pépite de ce weekend d’intégration ! Inspiré par les
|
||||||
|
icônes suprêmes de la pop culture telles les Bratz, les Winx et autres Mean Girls, notre bus est un sanctuaire de style,
|
||||||
|
d’audace et de pur plaisir. A nos cotés attends toi à siroter tes meilleurs Cosmo, sex on the Beach et autres cocktails
|
||||||
|
de maxi pétasse tout en papotant entre copains copines ! Si tu rejoins le Kardi Bus, tu entres dans un monde où tu
|
||||||
|
pourras te déhancher sur du Beyoncé, Britney, Aya et autres reines de la pop ! À très vite, les futures stars du Kardi
|
||||||
|
Bus !""",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Sparta[bus] 🐺🐒🏉", "#ebdac2", 5,
|
||||||
|
"""Dans notre bus, on vous donne un avant goût des plus grandes assos de l'ENS : les Kyottes et l'Aspique (clubs de rugby
|
||||||
|
féminin et masculin, mais pas que). Bien entendu, qui dit rugby dit les copaings, le pastaga et la Pena Bayona, mais vous
|
||||||
|
verrez par vous même qu'on est ouvert⋅e à toutes propositions quand il s'agit de faire la fête. Pour les casse-cous comme
|
||||||
|
pour les plus calmes, vous trouverez au bus Aspique-Kyottes les 2A+ qui vous feront kiffer votre WEI.""",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Zanzo[Bus] 🤯🚸🐒", "#FFFF", 3,
|
||||||
|
"""Dans un entre-trois bien senti entre zinzinerie, enfance et vieillerie, le Zanzo[BUS] est un concentré de fun mêlé à
|
||||||
|
de la dinguerie à gogo. N'hésitez plus et rejoignez-nous pour un WEI toujours plus déjanté !""",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Bran[Kar] 🍹🥳", "#6da1ac", 4,
|
||||||
|
"""Si vous ne connaissez pas le Bran[Kar], c’est comme une grande famille qui fait un apéro, qui se bourre un peu la
|
||||||
|
gueule en discutant des heures autour d’une table remplie de bouffe et de super bons cocktails (la plupart des
|
||||||
|
barmen/barwomen du bus sont les barmans de Shakens), sauf qu’on est un bus du Wei (vous comprendrez bien le nom de notre
|
||||||
|
bus en voyant l’état de certain·e·s). Il nous arrive de faire quelques conneries, mais surtout de jouer au Bière-pong en
|
||||||
|
musique !""",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Techno [kar]ade 🔊🚩", "#8065a3", 3,
|
||||||
|
"""Avis à tous·tes les gauchos, amoureux·ses de la fête et des manifs : le Techno [kar]ade vous ouvre grand ses bras pour
|
||||||
|
finir en beauté votre première inté. Préparez-vous à vous abreuver de cocktails (savamment élaborés) à la vibration d’un
|
||||||
|
système son fabriqué pour l’occasion. Des sets technos à « Mon père était tellement de gauche » en passant par « Female
|
||||||
|
Body », le car accueillant les meilleures DJs du plateau saura animer le trajet aussi bien que les soirées. Si alcool et
|
||||||
|
musique seront au rendez-vous, les maîtres mots sont sécurité et inclusivité. Qui que vous soyez et quelle que soit votre
|
||||||
|
manière de vous amuser, notre objectif est que vous vous sentiez à l’aise pour rencontrer au mieux les 1A, les 2A et les
|
||||||
|
(nombreux⋅ses) 3A+ qui auront répondu à l’appel. Bref, rejoignez-nous, on est super cools :)"""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"[Bus]ka-P 🥇🍻🎤", "#7c4768", 4.5,
|
||||||
|
"""Booska-p, c’est le « site N°1 du Rap français ». Le [Bus]ka-p ? Le bus N°1 sur l’ambiance au WEI. Les nuits vont être
|
||||||
|
courtes, les cocktails vont couler à flots : tout sera réuni pour vivre un week-end dont tu te souviendras toute ta vie.
|
||||||
|
Au programme pas un seul temps mort et un maximum de rencontres pour bien commencer ta première année à l’ENS. Et bien
|
||||||
|
entendu, le tout accompagné des meilleurs sons, de Jul à Aya, en passant par ABBA et Sexion d’Assaut. Bref, si tu veux
|
||||||
|
vivre un WEI d’anthologie et faire la fête, de jour comme de nuit, nous t’accueillons avec plaisir !""",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def print_bus(i):
|
||||||
|
return f"""<h1 style="color:{buses_descr[i][1]};-webkit-text-stroke: 2px black;font-size: 50px;">{buses_descr[i][0]}</h1><br>
|
||||||
|
<b>Alcoolomètre : {buses_descr[i][2]} / 5 🍻</b><br><br>{buses_descr[i][3]}<br>"""
|
||||||
|
|
||||||
|
|
||||||
|
def print_all_buses():
|
||||||
|
liste = [print_bus(i) for i in range(len(buses_descr))]
|
||||||
|
return "<br><br><br><br>".join(liste)
|
||||||
|
|
||||||
|
|
||||||
|
def get_number_comment(i):
|
||||||
|
if i == 1:
|
||||||
|
return "Même pas en rêve"
|
||||||
|
elif i == 2:
|
||||||
|
return "Pas envie"
|
||||||
|
elif i == 3:
|
||||||
|
return "Mouais..."
|
||||||
|
elif i == 4:
|
||||||
|
return "Pourquoi pas !"
|
||||||
|
elif i == 5:
|
||||||
|
return "Ce bus ou rien !!!"
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
WORDS = {
|
||||||
|
"recap":
|
||||||
|
[
|
||||||
|
"""<b>Chèr⋅e 1A, te voilà arrivé⋅e au moment fatidique du choix de ton bus !<br><br><br>
|
||||||
|
Ton bus est constitué des gens avec qui tu passeras la majorité de ton temps : que ce soit le voyage d'aller et de
|
||||||
|
retour et les différentes activité qu'ils pourront te proposer tout au long du WEI donc choisis le bien !
|
||||||
|
<br><br>Tu trouveras ci-dessous la liste de tous les bus ainsi qu'une description détaillée de ces derniers.
|
||||||
|
Prends ton temps pour étudier chacun d'eux et quand tu te sens prêt⋅e, appuie sur le bouton « J'ai pris connaissance
|
||||||
|
des bus » pour continuer
|
||||||
|
<br>(pas besoin d'apprendre par cœur chaque bus, la description de chaque bus te sera rappeler avant de lui attribuer
|
||||||
|
une note !)</b><br><br><br>""" + print_all_buses(),
|
||||||
|
{
|
||||||
|
"1": "J'ai pris connaissance des différents bus et me sent fin prêt à choisir celui qui me convient le mieux !",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
WORDS.update({
|
||||||
|
f"bus{id}": [print_bus(id), {i: f"{get_number_comment(i)} ({i}/5)" for i in range(1, 5 + 1)}] for id in range(len(buses_descr))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurveyForm2024(forms.Form):
|
||||||
|
"""
|
||||||
|
Survey form for the year 2024.
|
||||||
|
Members score the different buses, from which we calculate the best associated bus.
|
||||||
|
"""
|
||||||
|
def set_registration(self, registration):
|
||||||
|
"""
|
||||||
|
Filter the bus selector with the buses of the current WEI.
|
||||||
|
"""
|
||||||
|
information = WEISurveyInformation2024(registration)
|
||||||
|
|
||||||
|
question = information.questions[information.step]
|
||||||
|
self.fields[question] = forms.ChoiceField(
|
||||||
|
label=mark_safe(WORDS[question][0]),
|
||||||
|
widget=forms.RadioSelect(),
|
||||||
|
)
|
||||||
|
answers = [(answer, WORDS[question][1][answer]) for answer in WORDS[question][1]]
|
||||||
|
self.fields[question].choices = answers
|
||||||
|
|
||||||
|
|
||||||
|
class WEIBusInformation2024(WEIBusInformation):
|
||||||
|
"""
|
||||||
|
For each question, the bus has ordered answers
|
||||||
|
"""
|
||||||
|
scores: dict
|
||||||
|
|
||||||
|
def __init__(self, bus):
|
||||||
|
self.scores = {}
|
||||||
|
for question in WORDS:
|
||||||
|
self.scores[question] = []
|
||||||
|
super().__init__(bus)
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurveyInformation2024(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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
step = 0
|
||||||
|
questions = list(WORDS.keys())
|
||||||
|
|
||||||
|
def __init__(self, registration):
|
||||||
|
for question in WORDS:
|
||||||
|
setattr(self, str(question), None)
|
||||||
|
super().__init__(registration)
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurvey2024(WEISurvey):
|
||||||
|
"""
|
||||||
|
Survey for the year 2024.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_year(cls):
|
||||||
|
return 2024
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_survey_information_class(cls):
|
||||||
|
return WEISurveyInformation2024
|
||||||
|
|
||||||
|
def get_form_class(self):
|
||||||
|
return WEISurveyForm2024
|
||||||
|
|
||||||
|
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):
|
||||||
|
self.information.step += 1
|
||||||
|
for question in WORDS:
|
||||||
|
if question in form.cleaned_data:
|
||||||
|
answer = form.cleaned_data[question]
|
||||||
|
setattr(self.information, question, answer)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_algorithm_class(cls):
|
||||||
|
return WEISurveyAlgorithm2024
|
||||||
|
|
||||||
|
def is_complete(self) -> bool:
|
||||||
|
"""
|
||||||
|
The survey is complete once the bus is chosen.
|
||||||
|
"""
|
||||||
|
for question in WORDS:
|
||||||
|
if not getattr(self.information, question):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@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 = 0
|
||||||
|
for question in WORDS:
|
||||||
|
s += bus_info.scores[question][str(getattr(self.information, question))]
|
||||||
|
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):
|
||||||
|
return super().clear_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurveyAlgorithm2024(WEISurveyAlgorithm):
|
||||||
|
"""
|
||||||
|
The algorithm class for the year 2024.
|
||||||
|
We use Gale-Shapley algorithm to attribute 1y students into buses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_survey_class(cls):
|
||||||
|
return WEISurvey2024
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_bus_information_class(cls):
|
||||||
|
return WEIBusInformation2024
|
||||||
|
|
||||||
|
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 s.bus_id != None]
|
||||||
|
# surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
|
||||||
|
|
||||||
|
|
||||||
|
# surveys = [s for s in surveys if s.registration.user_id in free_users]
|
||||||
|
|
||||||
|
# hardcoded_first_year_mb = WEIMembership.objects.filter(bus != None,registration__first_year=True)
|
||||||
|
# hardcoded_first_year = hardcoded_first_year_mb.values_list('user__id', 'bus__id')
|
||||||
|
|
||||||
|
hardcoded_first_year_mb = WEIMembership.objects.filter(registration__first_year=True)
|
||||||
|
hardcoded_first_year = {mb.user.id if mb.bus else None: mb.bus.id if mb.bus else None for mb in hardcoded_first_year_mb}
|
||||||
|
|
||||||
|
|
||||||
|
# Reset previous algorithm run
|
||||||
|
for survey in surveys:
|
||||||
|
survey.free()
|
||||||
|
if survey.registration.user_id in hardcoded_first_year.keys():
|
||||||
|
survey.select_bus(hardcoded_first_year[s.registration.user_id])
|
||||||
|
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()
|
||||||
|
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
WEISurvey2024.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()
|
18
apps/wei/migrations/0009_weiregistration_specific_diet.py
Normal file
18
apps/wei/migrations/0009_weiregistration_specific_diet.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-08-28 20:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wei', '0008_auto_20240111_1545'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='specific_diet',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='specific diet'),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-08-29 20:15
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wei', '0009_weiregistration_specific_diet'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='specific_diet',
|
||||||
|
),
|
||||||
|
]
|
@ -12,7 +12,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% render_table bus_repartition_table %}
|
{% render_table bus_repartition_table %}
|
||||||
<hr>
|
<hr>
|
||||||
<a href="{% url 'wei:wei_bus_1A_next' pk=club.pk %}" class="btn btn-block btn-success">{% trans "Start attribution!" %}</a>
|
<a href="{% url 'wei:wei_bus_1A_next' pk=club.pk %}" class="btn btn-block btn-success">{% trans "Start attribution !" %}</a>
|
||||||
<hr>
|
<hr>
|
||||||
{% render_table table %}
|
{% render_table table %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
<dt class="col-xl-6">{% trans 'department'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'department'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ object.user.profile.get_department_display }}</dd>
|
<dd class="col-xl-6">{{ object.user.profile.get_department_display }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6">{% trans 'health issues'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'health issues or specific diet'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ object.health_issues|default:"—" }}</dd>
|
<dd class="col-xl-6">{{ object.health_issues|default:"—" }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6">{% trans 'suggested bus'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'suggested bus'|capfirst %}</dt>
|
||||||
|
@ -64,7 +64,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<dt class="col-xl-6">{% trans 'birth date'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'birth date'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ registration.birth_date }}</dd>
|
<dd class="col-xl-6">{{ registration.birth_date }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6">{% trans 'health issues'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'health issues or specific diet'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ registration.health_issues }}</dd>
|
<dd class="col-xl-6">{{ registration.health_issues }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6">{% trans 'emergency contact name'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'emergency contact name'|capfirst %}</dt>
|
||||||
|
@ -6,8 +6,6 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
|
||||||
from note.models import NoteUser
|
|
||||||
|
|
||||||
from ..forms.surveys.wei2023 import WEIBusInformation2023, WEISurvey2023, WORDS, WEISurveyInformation2023
|
from ..forms.surveys.wei2023 import WEIBusInformation2023, WEISurvey2023, WORDS, WEISurveyInformation2023
|
||||||
from ..models import Bus, WEIClub, WEIRegistration
|
from ..models import Bus, WEIClub, WEIRegistration
|
||||||
@ -127,44 +125,3 @@ class TestWEIAlgorithm(TestCase):
|
|||||||
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
|
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
|
||||||
|
|
||||||
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
||||||
|
|
||||||
def test_register_1a(self):
|
|
||||||
"""
|
|
||||||
Test register a first year member to the WEI and complete the survey
|
|
||||||
"""
|
|
||||||
response = self.client.get(reverse("wei:wei_register_1A", 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,
|
|
||||||
birth_date=date(2000, 1, 1),
|
|
||||||
gender='nonbinary',
|
|
||||||
clothing_cut='female',
|
|
||||||
clothing_size='XS',
|
|
||||||
health_issues='I am a bot',
|
|
||||||
emergency_contact_name='NoteKfet2020',
|
|
||||||
emergency_contact_phone='+33123456789',
|
|
||||||
))
|
|
||||||
qs = WEIRegistration.objects.filter(user_id=user.id)
|
|
||||||
self.assertTrue(qs.exists())
|
|
||||||
registration = qs.get()
|
|
||||||
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302, 200)
|
|
||||||
for question in WORDS:
|
|
||||||
# Fill 1A Survey, 20 pages
|
|
||||||
# be careful if questionnary form change (number of page, type of answer...)
|
|
||||||
response = self.client.post(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), {
|
|
||||||
question: "1"
|
|
||||||
})
|
|
||||||
registration.refresh_from_db()
|
|
||||||
survey = WEISurvey2023(registration)
|
|
||||||
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302,
|
|
||||||
302 if survey.is_complete() else 200)
|
|
||||||
self.assertIsNotNone(getattr(survey.information, question), "Survey page " + question + " failed")
|
|
||||||
survey = WEISurvey2023(registration)
|
|
||||||
self.assertTrue(survey.is_complete())
|
|
||||||
survey.select_bus(self.buses[0])
|
|
||||||
survey.save()
|
|
||||||
self.assertIsNotNone(survey.information.get_selected_bus())
|
|
||||||
|
172
apps/wei/tests/test_wei_algorithm_2024.py
Normal file
172
apps/wei/tests/test_wei_algorithm_2024.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from note.models import NoteUser
|
||||||
|
|
||||||
|
from ..forms.surveys.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024
|
||||||
|
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.user = User.objects.create_superuser(
|
||||||
|
username="weiadmin",
|
||||||
|
password="admin",
|
||||||
|
email="admin@example.com",
|
||||||
|
)
|
||||||
|
self.user.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
sess = self.client.session
|
||||||
|
sess["permission_mask"] = 42
|
||||||
|
sess.save()
|
||||||
|
|
||||||
|
self.wei = WEIClub.objects.create(
|
||||||
|
name="WEI 2024",
|
||||||
|
email="wei2024@example.com",
|
||||||
|
parent_club_id=2,
|
||||||
|
membership_fee_paid=12500,
|
||||||
|
membership_fee_unpaid=5500,
|
||||||
|
membership_start='2024-01-01',
|
||||||
|
membership_end='2024-12-31',
|
||||||
|
date_start=date.today() + timedelta(days=2),
|
||||||
|
date_end='2024-12-31',
|
||||||
|
year=2024,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = WEIBusInformation2024(bus)
|
||||||
|
for question in WORDS:
|
||||||
|
information.scores[question] = {answer: random.randint(1, 5) for answer in WORDS[question][1]}
|
||||||
|
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 = WEISurveyInformation2024(registration)
|
||||||
|
for question in WORDS:
|
||||||
|
options = list(WORDS[question][1].keys())
|
||||||
|
setattr(information, question, random.choice(options))
|
||||||
|
information.step = 20
|
||||||
|
information.save(registration)
|
||||||
|
registration.save()
|
||||||
|
|
||||||
|
# Run algorithm
|
||||||
|
WEISurvey2024.get_algorithm_class()().run_algorithm()
|
||||||
|
|
||||||
|
# Ensure that everyone has its first choice
|
||||||
|
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||||
|
survey = WEISurvey2024(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 = WEISurveyInformation2024(registration)
|
||||||
|
for question in WORDS:
|
||||||
|
options = list(WORDS[question][1].keys())
|
||||||
|
setattr(information, question, random.choice(options))
|
||||||
|
information.step = 20
|
||||||
|
information.save(registration)
|
||||||
|
registration.save()
|
||||||
|
|
||||||
|
# Run algorithm
|
||||||
|
WEISurvey2024.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 = WEISurvey2024(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 %
|
||||||
|
|
||||||
|
def test_register_1a(self):
|
||||||
|
"""
|
||||||
|
Test register a first year member to the WEI and complete the survey
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("wei:wei_register_1A", 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,
|
||||||
|
birth_date=date(2000, 1, 1),
|
||||||
|
gender='nonbinary',
|
||||||
|
clothing_cut='female',
|
||||||
|
clothing_size='XS',
|
||||||
|
health_issues='I am a bot',
|
||||||
|
emergency_contact_name='NoteKfet2020',
|
||||||
|
emergency_contact_phone='+33123456789',
|
||||||
|
))
|
||||||
|
qs = WEIRegistration.objects.filter(user_id=user.id)
|
||||||
|
self.assertTrue(qs.exists())
|
||||||
|
registration = qs.get()
|
||||||
|
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302, 200)
|
||||||
|
for question in WORDS:
|
||||||
|
# Fill 1A Survey, 10 pages
|
||||||
|
# be careful if questionnary form change (number of page, type of answer...)
|
||||||
|
response = self.client.post(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), {
|
||||||
|
question: "1"
|
||||||
|
})
|
||||||
|
registration.refresh_from_db()
|
||||||
|
survey = WEISurvey2024(registration)
|
||||||
|
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302,
|
||||||
|
302 if survey.is_complete() else 200)
|
||||||
|
self.assertIsNotNone(getattr(survey.information, question), "Survey page " + question + " failed")
|
||||||
|
survey = WEISurvey2024(registration)
|
||||||
|
self.assertTrue(survey.is_complete())
|
||||||
|
survey.select_bus(self.buses[0])
|
||||||
|
survey.save()
|
||||||
|
self.assertIsNotNone(survey.information.get_selected_bus())
|
@ -439,7 +439,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
emergency_contact_phone='+33123456789',
|
emergency_contact_phone='+33123456789',
|
||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue("This user can't be in her/his first year since he/she has already participated to a WEI."
|
self.assertTrue("This user can't be in her/his first year since he/she has already participated to a WEI."
|
||||||
in str(response.context["form"].errors))
|
in str(response.context["form"].errors))
|
||||||
|
|
||||||
# Check that if the WEI is started, we can't register anyone
|
# Check that if the WEI is started, we can't register anyone
|
||||||
@ -635,7 +635,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(response.context["form"].is_valid())
|
self.assertFalse(response.context["form"].is_valid())
|
||||||
self.assertTrue("This team doesn't belong to the given bus." in str(response.context["form"].errors))
|
self.assertTrue("This team doesn't belong to the given bus." in str(response.context["form"].errors))
|
||||||
|
|
||||||
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
|
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
|
||||||
roles=[WEIRole.objects.get(name="GC WEI").id],
|
roles=[WEIRole.objects.get(name="GC WEI").id],
|
||||||
@ -767,7 +767,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(), 2023)
|
self.assertEqual(CurrentSurvey.get_year(), 2024)
|
||||||
|
|
||||||
|
|
||||||
class TestWeiAPI(TestAPI):
|
class TestWeiAPI(TestAPI):
|
||||||
|
@ -22,7 +22,8 @@ from django.views import View
|
|||||||
from django.views.generic import DetailView, UpdateView, RedirectView, TemplateView
|
from django.views.generic import DetailView, UpdateView, RedirectView, TemplateView
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic.edit import BaseFormView, DeleteView
|
from django.views.generic.edit import BaseFormView, DeleteView
|
||||||
from django_tables2 import SingleTableView
|
from django_tables2 import SingleTableView, MultiTableMixin
|
||||||
|
from api.viewsets import is_regex
|
||||||
from member.models import Membership, Club
|
from member.models import Membership, Club
|
||||||
from note.models import Transaction, NoteClub, Alias, SpecialTransaction, NoteSpecial
|
from note.models import Transaction, NoteClub, Alias, SpecialTransaction, NoteSpecial
|
||||||
from note.tables import HistoryTable
|
from note.tables import HistoryTable
|
||||||
@ -100,7 +101,7 @@ class WEICreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.pk})
|
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
|
||||||
class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
View WEI information
|
View WEI information
|
||||||
"""
|
"""
|
||||||
@ -108,34 +109,40 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
context_object_name = "club"
|
context_object_name = "club"
|
||||||
extra_context = {"title": _("WEI Detail")}
|
extra_context = {"title": _("WEI Detail")}
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
tables = [
|
||||||
context = super().get_context_data(**kwargs)
|
lambda data: HistoryTable(data, prefix="history-"),
|
||||||
|
lambda data: WEIMembershipTable(data, prefix="membership-"),
|
||||||
club = context["club"]
|
lambda data: WEIRegistrationTable(data, prefix="pre-registration-"),
|
||||||
|
lambda data: BusTable(data, prefix="bus-"),
|
||||||
|
]
|
||||||
|
paginate_by = 20 # number of rows in tables
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
club = self.object
|
||||||
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \
|
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \
|
||||||
.filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \
|
.filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \
|
||||||
.order_by('-created_at', '-id')
|
.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))
|
|
||||||
context['history_list'] = history_table
|
|
||||||
|
|
||||||
club_member = WEIMembership.objects.filter(
|
club_member = WEIMembership.objects.filter(
|
||||||
club=club,
|
club=club,
|
||||||
date_end__gte=date.today(),
|
date_end__gte=date.today(),
|
||||||
).filter(PermissionBackend.filter_queryset(self.request, 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(
|
pre_registrations = WEIRegistration.objects.filter(
|
||||||
PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter(
|
PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter(
|
||||||
membership=None,
|
membership=None,
|
||||||
wei=club
|
wei=club
|
||||||
)
|
)
|
||||||
pre_registrations_table = WEIRegistrationTable(data=pre_registrations, prefix="pre-registration-")
|
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
|
||||||
pre_registrations_table.paginate(per_page=20, page=self.request.GET.get('pre-registration-page', 1))
|
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
|
||||||
context['pre_registrations'] = pre_registrations_table
|
return [club_transactions, club_member, pre_registrations, buses, ]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
club = context["club"]
|
||||||
|
|
||||||
|
tables = context["tables"]
|
||||||
|
for name, table in zip(["history_list", "member_list", "pre_registrations", "buses"], tables):
|
||||||
|
context[name] = table
|
||||||
|
|
||||||
my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user)
|
my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user)
|
||||||
if my_registration.exists():
|
if my_registration.exists():
|
||||||
@ -144,11 +151,6 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
my_registration = None
|
my_registration = None
|
||||||
context["my_registration"] = my_registration
|
context["my_registration"] = my_registration
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
random_user = User.objects.filter(~Q(wei__wei__in=[club])).first()
|
random_user = User.objects.filter(~Q(wei__wei__in=[club])).first()
|
||||||
|
|
||||||
if random_user is None:
|
if random_user is None:
|
||||||
@ -219,13 +221,18 @@ class WEIMembershipsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
|
|||||||
if not pattern:
|
if not pattern:
|
||||||
return qs.none()
|
return qs.none()
|
||||||
|
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix_alias = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
suffix = "__iregex" if valid_regex else "__icontains"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(user__first_name__iregex=pattern)
|
Q(**{f"user__first_name{suffix}": pattern})
|
||||||
| Q(user__last_name__iregex=pattern)
|
| Q(**{f"user__last_name{suffix}": pattern})
|
||||||
| Q(user__note__alias__name__iregex="^" + pattern)
|
| Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
|
||||||
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
| Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
|
||||||
| Q(bus__name__iregex=pattern)
|
| Q(**{f"bus__name{suffix}": pattern})
|
||||||
| Q(team__name__iregex=pattern)
|
| Q(**{f"team__name{suffix}": pattern})
|
||||||
)
|
)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
@ -255,11 +262,16 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable
|
|||||||
pattern = self.request.GET.get("search", "")
|
pattern = self.request.GET.get("search", "")
|
||||||
|
|
||||||
if pattern:
|
if pattern:
|
||||||
|
# Check if this is a valid regex. If not, we won't check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix_alias = "__iregex" if valid_regex else "__istartswith"
|
||||||
|
suffix = "__iregex" if valid_regex else "__icontains"
|
||||||
|
prefix = "^" if valid_regex else ""
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(user__first_name__iregex=pattern)
|
Q(**{f"user__first_name{suffix}": pattern})
|
||||||
| Q(user__last_name__iregex=pattern)
|
| Q(**{f"user__last_name{suffix}": pattern})
|
||||||
| Q(user__note__alias__name__iregex="^" + pattern)
|
| Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
|
||||||
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
| Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
|
||||||
)
|
)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
@ -888,6 +900,9 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
form.fields["last_name"].initial = registration.user.last_name
|
form.fields["last_name"].initial = registration.user.last_name
|
||||||
form.fields["first_name"].initial = registration.user.first_name
|
form.fields["first_name"].initial = registration.user.first_name
|
||||||
|
|
||||||
|
if "caution_check" in form.fields:
|
||||||
|
form.fields["caution_check"].initial = registration.caution_check
|
||||||
|
|
||||||
if registration.soge_credit:
|
if registration.soge_credit:
|
||||||
form.fields["credit_type"].disabled = True
|
form.fields["credit_type"].disabled = True
|
||||||
form.fields["credit_type"].initial = NoteSpecial.objects.get(special_type="Virement bancaire")
|
form.fields["credit_type"].initial = NoteSpecial.objects.get(special_type="Virement bancaire")
|
||||||
@ -929,6 +944,9 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
club = registration.wei
|
club = registration.wei
|
||||||
user = registration.user
|
user = registration.user
|
||||||
|
|
||||||
|
if "caution_check" in form.data:
|
||||||
|
registration.caution_check = form.data["caution_check"] == "on"
|
||||||
|
registration.save()
|
||||||
membership = form.instance
|
membership = form.instance
|
||||||
membership.user = user
|
membership.user = user
|
||||||
membership.club = club
|
membership.club = club
|
||||||
|
@ -8,7 +8,7 @@ peuvent être diffusées via des calendriers ou la mailing list d'événements.
|
|||||||
Modèles
|
Modèles
|
||||||
-------
|
-------
|
||||||
|
|
||||||
L'application comporte 5 modèles : activités, types d'activité, invité⋅es, entrées et transactions d'invitation.
|
L'application comporte 6 modèles : activités, types d'activité, invité⋅es, entrées et transactions d'invitation et les ouvreur⋅ses.
|
||||||
|
|
||||||
Types d'activité
|
Types d'activité
|
||||||
~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~
|
||||||
@ -71,6 +71,17 @@ comportent qu'un champ supplémentaire, de type ``OneToOneField`` vers ``Guest``
|
|||||||
Ce modèle aurait pu appartenir à l'application ``note``, mais afin de rester modulaire et que l'application ``note``
|
Ce modèle aurait pu appartenir à l'application ``note``, mais afin de rester modulaire et que l'application ``note``
|
||||||
ne dépende pas de cette application, on procède de cette manière.
|
ne dépende pas de cette application, on procède de cette manière.
|
||||||
|
|
||||||
|
Ouvreur⋅ses
|
||||||
|
~~~~~~~~~~~
|
||||||
|
|
||||||
|
Depuis la page d'une activité, il est possible d'ajouter des personnes en tant qu'« ouvreur⋅se ». Cela permet à une
|
||||||
|
personne sans aucun droit note de pouvoir faire les entrées d'une ``Activity``. Ce rôle n'est valable que pendant que
|
||||||
|
l'activité est ouverte et sur aucune autre activité. Les ouvreur⋅ses ont aussi accès à l'interface des transactions.
|
||||||
|
|
||||||
|
Ce modèle regroupe :
|
||||||
|
* Activité (clé étrangère)
|
||||||
|
* Note (clé étrangère)
|
||||||
|
|
||||||
Graphe
|
Graphe
|
||||||
~~~~~~
|
~~~~~~
|
||||||
|
|
||||||
@ -108,3 +119,6 @@ apparaîssent, afin de régler la taxe d'invitation : l'un prélève directement
|
|||||||
permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent
|
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
|
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 du club organisateur de l'événement.
|
la note de l'hôte vers la note du club organisateur de l'événement.
|
||||||
|
|
||||||
|
Si une personne souhaite faire les entrées, il est possible de l'ajouter dans la liste des ouvreur⋅ses depuis la page
|
||||||
|
de l'activité.
|
||||||
|
83
docs/apps/food.rst
Normal file
83
docs/apps/food.rst
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
Application Food
|
||||||
|
================
|
||||||
|
|
||||||
|
L'application ``food`` s'occupe de la traçabilité et permet notamment l'obtention de la liste des allergènes.
|
||||||
|
|
||||||
|
Modèles
|
||||||
|
-------
|
||||||
|
|
||||||
|
L'application comporte 5 modèles : Allergen, QRCode, Food, BasicFood, TransformedFood.
|
||||||
|
|
||||||
|
Food
|
||||||
|
~~~~
|
||||||
|
|
||||||
|
Ce modèle est un PolymorphicModel et ne sert uniquement à créer BasicFood et TransformedFood.
|
||||||
|
|
||||||
|
Le modèle regroupe :
|
||||||
|
|
||||||
|
* Nom du produit
|
||||||
|
* Propriétaire (doit-être un Club)
|
||||||
|
* Allergènes (ManyToManyField)
|
||||||
|
* date d'expiration
|
||||||
|
* a été mangé (booléen)
|
||||||
|
* est prêt (booléen)
|
||||||
|
|
||||||
|
BasicFood
|
||||||
|
~~~~~~~~~
|
||||||
|
|
||||||
|
Les BasicFood correspondent aux produits non modifiés à la Kfet. Ils peuvent correspondre à la fois à des produits achetés en magasin ou à des produits Terre à Terre. Ces produits seront les ingrédients de tous les plats préparés et en conséquent sont les seuls produits à nécessité une saisie manuelle des allergènes.
|
||||||
|
|
||||||
|
Le modèle regroupe :
|
||||||
|
|
||||||
|
* Type de date (DLC = date limite de consommation, DDM = date de durabilité minimale)
|
||||||
|
* Date d'arrivée
|
||||||
|
* Champs de Food
|
||||||
|
|
||||||
|
TransformedFood
|
||||||
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Les TransformedFood correspondent aux produits préparés à la Kfet. Ils peuvent être composés de BasicFood et/ou de TransformedFood. La date d'expiration et les allergènes sont automatiquement mis à jour par update (qui doit être exécuté après modification des ingrédients dans les forms par exemple).
|
||||||
|
|
||||||
|
Le modèle regroupe :
|
||||||
|
|
||||||
|
* Durée de consommation (par défaut 3 jours)
|
||||||
|
* Ingrédients (ManyToManyField vers Food)
|
||||||
|
* Date de création
|
||||||
|
* Champs de Food
|
||||||
|
|
||||||
|
Allergen
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
Le modèle regroupe :
|
||||||
|
|
||||||
|
* Nom
|
||||||
|
|
||||||
|
QRCode
|
||||||
|
~~~~~~
|
||||||
|
|
||||||
|
Le modèle regroupe :
|
||||||
|
|
||||||
|
* nombre (unique, entier positif)
|
||||||
|
* food (OneToOneField vers Food)
|
||||||
|
|
||||||
|
Création de BasicFood
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Un BasicFood a toujours besoin d'un QRCode (depuis l'interface web). Il convient donc de coller le QRCode puis de le scanner et de compléter le formulaire.
|
||||||
|
|
||||||
|
Création de TransformedFood
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Pour créer un TransformedFood, il suffit d'aller dans l'onglet ``traçabilité`` et de cliquer sur l'onglet.
|
||||||
|
|
||||||
|
Ajouter un ingrédient
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Un ingrédient a forcément un QRCode. Il convient donc de scanner le QRCode de l'ingrédient et de sélectionner le produit auquel il doit être ajouté.
|
||||||
|
|
||||||
|
Remarque : Un produit fini doit avoir un QRCode et inversement.
|
||||||
|
|
||||||
|
Terminer un plat
|
||||||
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Il suffit de coller le QRCode sur le plat, de le scanner et de sélectionner le produit.
|
@ -32,7 +32,7 @@ Applications indispensables
|
|||||||
* `Note <note>`_ :
|
* `Note <note>`_ :
|
||||||
Les notes associées à des utilisateur⋅rices ou des clubs.
|
Les notes associées à des utilisateur⋅rices ou des clubs.
|
||||||
* `Activity <activity>`_ :
|
* `Activity <activity>`_ :
|
||||||
La gestion des activités (créations, gestion, entrées,…)
|
La gestion des activités (créations, gestion, entrées, ...)
|
||||||
* `Permission <permission>`_ :
|
* `Permission <permission>`_ :
|
||||||
Backend de droits, limites les pouvoirs des utilisateur⋅rices
|
Backend de droits, limites les pouvoirs des utilisateur⋅rices
|
||||||
* `API <../api>`_ :
|
* `API <../api>`_ :
|
||||||
@ -51,7 +51,7 @@ Applications packagées
|
|||||||
`<https://django-polymorphic.readthedocs.io/en/stable/>`_
|
`<https://django-polymorphic.readthedocs.io/en/stable/>`_
|
||||||
|
|
||||||
* ``crispy_forms``
|
* ``crispy_forms``
|
||||||
Utiliser pour générer des formulairess avec Bootstrap4
|
Utiliser pour générer des formulaires avec Bootstrap4
|
||||||
* ``django_tables2``
|
* ``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``
|
* ``restframework``
|
||||||
@ -64,9 +64,9 @@ Applications facultatives
|
|||||||
* ``cas-server``
|
* ``cas-server``
|
||||||
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
|
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>`_
|
* `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…
|
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc...
|
||||||
* `Treasury <treasury>`_ :
|
* `Treasury <treasury>`_ :
|
||||||
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques ...
|
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques...
|
||||||
* `WEI <wei>`_ :
|
* `WEI <wei>`_ :
|
||||||
Interface de gestion du WEI.
|
Interface de gestion du WEI.
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ Utilisateur⋅rice
|
|||||||
Le modèle ``User`` est directement implémenté dans Django et n'appartient pas à l'application ``member``, mais il est
|
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.
|
bon de rappeler à quoi ressemble ce modèle.
|
||||||
|
|
||||||
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur⋅rice a été inscrit (*inutilisé dans la Note*)
|
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur⋅rice a été inscrit⋅e (*inutilisé dans la Note*)
|
||||||
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur⋅rice.
|
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur⋅rice.
|
||||||
* ``first_name`` : ``CharField``, prénom 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_active`` : ``BooleanField``, indique si le compte est actif et peut se connecter.
|
||||||
@ -43,7 +43,7 @@ l'utilisateur⋅rice, utiles pour l'adhésion au BDE :
|
|||||||
* ``address`` : ``CharField``, adresse physique de l'utilisateur⋅rice
|
* ``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)
|
* ``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
|
* ``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,…)
|
* ``section`` : ``CharField``, section de l'ENS à laquelle appartient l'utilisateur⋅rice (exemple : 1A0, ...)
|
||||||
|
|
||||||
Clubs
|
Clubs
|
||||||
~~~~~
|
~~~~~
|
||||||
@ -101,7 +101,7 @@ 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
|
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érent⋅es, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
|
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,…).
|
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
|
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 €.
|
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
|
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
|
||||||
|
@ -20,7 +20,7 @@ note et la photo, si toutefois l'utilisateur⋅rice a le droit de voir ceci.
|
|||||||
L'utilisateur⋅rice peut cliquer sur des aliases pour ajouter des émetteur⋅rices, et sur des boutons pour ajouter des consommations.
|
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é.
|
Cliquer dans la liste des émetteur⋅rices supprime l'élément sélectionné.
|
||||||
|
|
||||||
Il ya deux possibilités pour faire consommer des adhérent⋅es :
|
Il y a 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
|
- En mode **consommation simple** (mode par défaut), les consommations sont débitées dès que émetteur⋅rices et consommations
|
||||||
sont renseignées.
|
sont renseignées.
|
||||||
- En mode **consommation double**, l'utilisateur⋅rice 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.
|
||||||
|
@ -6,7 +6,7 @@ L'application ``note`` gère tout ce qui est en lien avec les flux d'argent et l
|
|||||||
La gestion des consommations s'effectue principalement via la page dédiée, dont le fonctionnement est expliqué
|
La gestion des consommations s'effectue principalement via la page dédiée, dont le fonctionnement est expliqué
|
||||||
dans la page `Consommations <consumptions>`_.
|
dans la page `Consommations <consumptions>`_.
|
||||||
|
|
||||||
Le fonctionnement des crédit/débit de note (avec le « monde extérieur »» donc avec de l'argent réel) ainsi que les
|
Le fonctionnement des crédit/débit de note (avec le « monde extérieur » donc avec de l'argent réel) ainsi que les
|
||||||
transferts/dons entre notes est détaillé sur la page `Transferts <transactions>`_.
|
transferts/dons entre notes est détaillé sur la page `Transferts <transactions>`_.
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
@ -130,8 +130,8 @@ Exemples
|
|||||||
Masques de permissions
|
Masques de permissions
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
Chaque permission est associée à un masque. À la connexion, l'utilisateur⋅rice choisit le masque de droits avec lequel il
|
Chaque permission est associée à un masque. À la connexion, l'utilisateur⋅rice choisit le masque de droits avec lequel iel
|
||||||
souhaite se connecter. Les masques sont ordonnés totalement, et l'utilisateur⋅rice aura effectivement une permission s'il est
|
souhaite se connecter. Les masques sont ordonnés totalement, et l'utilisateur⋅rice aura effectivement une permission si iel est
|
||||||
en droit d'avoir la permission et si son masque est suffisamment haut.
|
en droit d'avoir la permission et si son masque est suffisamment haut.
|
||||||
|
|
||||||
Par exemple, si la permission de voir toutes les transactions est associée au masque « Droits note uniquement »,
|
Par exemple, si la permission de voir toutes les transactions est associée au masque « Droits note uniquement »,
|
||||||
|
@ -49,7 +49,7 @@ Une fois l'inscription validée, détail de ce qu'il se passe :
|
|||||||
lui octroyant un faible nombre de permissions de base, telles que la visualisation de son compte.
|
lui octroyant un faible nombre de permissions de base, telles que la visualisation de son compte.
|
||||||
* On adhère la personne au club Kfet si cela est demandé, l'adhésion commence aujourd'hui. Iel dispose d'un unique rôle :
|
* On adhère la personne au club Kfet si cela est demandé, l'adhésion commence aujourd'hui. Iel dispose d'un unique rôle :
|
||||||
« Adhérent⋅e Kfet » , lui octroyant un nombre un peu plus conséquent de permissions basiques, telles que la possibilité de
|
« Adhérent⋅e Kfet » , lui octroyant un nombre un peu plus conséquent de permissions basiques, telles que la possibilité de
|
||||||
faire des transactions, d'accéder aux activités, au WEI,…
|
faire des transactions, d'accéder aux activités, au WEI, ...
|
||||||
* Si læ nouvelleau membre a indiqué avoir ouvert un compte à la société générale, alors les transactions sont invalidées,
|
* Si læ nouvelleau membre a indiqué avoir ouvert un compte à la société générale, alors les transactions sont invalidées,
|
||||||
la note n'est pas débitée (commence alors à 0 €).
|
la note n'est pas débitée (commence alors à 0 €).
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ Champs hérités de ``Club`` de l'application ``member`` :
|
|||||||
|
|
||||||
* ``parent_club`` : ``ForeignKey(Club)``. Ce champ vaut toujours ``Kfet`` dans le cas d'un WEI : on doit être membre du
|
* ``parent_club`` : ``ForeignKey(Club)``. Ce champ vaut toujours ``Kfet`` dans le cas d'un WEI : on doit être membre du
|
||||||
club Kfet pour participer au WEI.
|
club Kfet pour participer au WEI.
|
||||||
* ``email`` : ``EmailField``, adresse e-mail sur laquelle contacter les gérants du WEI.
|
* ``email`` : ``EmailField``, adresse e-mail sur laquelle contacter les gérant⋅es du WEI.
|
||||||
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible de s'inscrire au WEI.
|
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible de s'inscrire au WEI.
|
||||||
* ``membership_end`` : ``DateField``, date de fin d'adhésion possible au WEI.
|
* ``membership_end`` : ``DateField``, date de fin d'adhésion possible au WEI.
|
||||||
* ``membership_duration`` : ``PositiveIntegerField``, inutilisé dans le cas d'un WEI, vaut ``None``.
|
* ``membership_duration`` : ``PositiveIntegerField``, inutilisé dans le cas d'un WEI, vaut ``None``.
|
||||||
@ -291,7 +291,7 @@ pour unique effet d'appeler la fonction ``run_algorithm`` décrite plus tôt. Un
|
|||||||
n'a pas été évoqué d'adhésion. L'adhésion est ensuite manuelle, l'algorithme ne fournit qu'une suggestion.
|
n'a pas été évoqué d'adhésion. L'adhésion est ensuite manuelle, l'algorithme ne fournit qu'une suggestion.
|
||||||
|
|
||||||
Cette structure, complexe mais raisonnable, permet de gérer plus ou moins proprement la répartition des 1A,
|
Cette structure, complexe mais raisonnable, permet de gérer plus ou moins proprement la répartition des 1A,
|
||||||
en limitant très fortement le hard code. Ami nouvelleeau développeur⋅se, merci de bien penser à la propreté du code :)
|
en limitant très fortement le hard code. Ami⋅e nouvelleau développeur⋅se, merci de bien penser à la propreté du code :)
|
||||||
En particulier, on évitera de mentionner dans le code le nom des bus, et profiter du champ ``information_json``
|
En particulier, on évitera de mentionner dans le code le nom des bus, et profiter du champ ``information_json``
|
||||||
présent dans le modèle ``Bus``.
|
présent dans le modèle ``Bus``.
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ Applications externes
|
|||||||
Puisque la Note Kfet recense tous les comptes des adhérent⋅es BDE, les clubs ont alors
|
Puisque la Note Kfet recense tous les comptes des adhérent⋅es BDE, les clubs ont alors
|
||||||
la possibilité de développer leurs propres applications et de les interfacer avec la
|
la possibilité de développer leurs propres applications et de les interfacer avec la
|
||||||
note. De cette façon, chaque application peut authentifier ses utilisateur⋅rices via la note,
|
note. De cette façon, chaque application peut authentifier ses utilisateur⋅rices via la note,
|
||||||
et récupérer leurs adhésion, leur nom de note afin d'éventuellement faire des transferts
|
et récupérer leurs adhésions, leur nom de note afin d'éventuellement faire des transferts
|
||||||
via l'API.
|
via l'API.
|
||||||
|
|
||||||
Deux protocoles d'authentification sont implémentées :
|
Deux protocoles d'authentification sont implémentées :
|
||||||
|
149
docs/faq.rst
149
docs/faq.rst
@ -6,24 +6,23 @@ Des transactions anormales sont apparues sur mon compte.
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Tu dois immédiatement contacter les trésorièr⋅es du BDE (voir ci-dessous) pour
|
Tu dois immédiatement contacter les trésorièr⋅es du BDE (voir ci-dessous) pour
|
||||||
signaler l'incident. Précise bien ton nom de note, l'heure de la transaction
|
signaler l'incident. Précise bien ton nom de note, l'heure de la transaction ainsi que
|
||||||
ainsi que l'alias utilisé pour faire la transaction (en plaçant ta souris sur
|
l'alias utilisé pour faire la transaction (en plaçant ta souris sur ton pseudo sur la
|
||||||
ton pseudo sur la ligne de transaction, l'alias utilisé apparaît). La raison
|
ligne de transaction, l'alias utilisé apparaît). La raison la plus courante est que tu
|
||||||
la plus courante est que tu as un alias qui est trop proche d'un autre d'une
|
as un alias qui est trop proche d'une autre personne. Même si la Note Kfet 2020 essaie
|
||||||
autre personne. Même si la Note Kfet 2020 essaie d'éviter ça, tu es invité⋅e
|
d'éviter ça, tu es invité⋅e à supprimer l'alias problématique, ou tout du moins
|
||||||
à supprimer l'alias problématique, ou tout du moins t'assurer que la confusion
|
t'assurer que la confusion ne puisse plus arriver.
|
||||||
ne puisse plus arriver.
|
|
||||||
|
|
||||||
|
|
||||||
Je souhaite consommer mais le solde de ma note est insuffisant
|
Je souhaite consommer mais le solde de ma note est insuffisant
|
||||||
--------------------------------------------------------------
|
--------------------------------------------------------------
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Le BDE ne fait pas crédit à ses adhérent⋅es. Il est de ton devoir de t'assurer
|
Le BDE ne fait pas crédit à ses adhérent⋅es. Il est de ton devoir de t'assurer d'avoir
|
||||||
d'avoir en permanence un solde positif sur ta note. Les permanencièr⋅es à la
|
en permanence un solde positif sur ta note. Les permanencièr⋅es à la Kfet ont la
|
||||||
Kfet ont la possibilité de refuser une consommation qui fait passer en négatif,
|
possibilité de refuser une consommation qui fait passer en négatif, et ont obligation
|
||||||
et ont obligation de refuser si la consommation est alcoolisée, en accord avec
|
de refuser si la consommation est alcoolisée, en accord avec la règlementation en
|
||||||
la règlementation en vigueur.
|
vigueur.
|
||||||
|
|
||||||
Les trésorièr⋅es connaissent la liste des personnes en situation irrégulière et
|
Les trésorièr⋅es connaissent la liste des personnes en situation irrégulière et
|
||||||
n'hésiteront pas à faire des rappels pour recharger la note.
|
n'hésiteront pas à faire des rappels pour recharger la note.
|
||||||
@ -33,10 +32,10 @@ Comment recharger ma note ?
|
|||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Le solde de la note peut être rechargé soit par espèces, par chèque à l'ordre
|
Le solde de la note peut être rechargé soit par espèces, par chèque à l'ordre de
|
||||||
de l'amicale des élèves de l'ENS Paris-Saclay, par carte bancaire via un terminal
|
l'amicale des élèves de l'ENS Paris-Saclay, par carte bancaire via un terminal de
|
||||||
de paiement électronique ou encore par virement bancaire, dont les coordonnées
|
paiement électronique ou encore par virement bancaire, dont les coordonnées sont à
|
||||||
sont à demander auprès des trésorièr⋅es BDE.
|
demander auprès des trésorièr⋅es BDE.
|
||||||
|
|
||||||
Les trois premières options sont à faire directement dans la Kfet.
|
Les trois premières options sont à faire directement dans la Kfet.
|
||||||
|
|
||||||
@ -45,9 +44,9 @@ Je pars en stage / en vacances. Puis-je bloquer ma note ?
|
|||||||
---------------------------------------------------------
|
---------------------------------------------------------
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Bien sûr : il te suffit de te rendre sur ton compte et de cliquer sur le bouton
|
Bien sûr : il te suffit de te rendre sur ton compte et de cliquer sur le bouton dédié.
|
||||||
dédié. Ta note ne sera plus affichée par les autres personnes et les transferts
|
Ta note ne sera plus affichée par les autres personnes et les transferts seront
|
||||||
seront impossibles, sauf pour les trésorièr⋅es BDE et respo info.
|
impossibles, sauf pour les trésorièr⋅es BDE et respo info.
|
||||||
|
|
||||||
Il est toutefois de ton devoir de rembourser tout ce que tu dois.
|
Il est toutefois de ton devoir de rembourser tout ce que tu dois.
|
||||||
|
|
||||||
@ -59,43 +58,42 @@ Quelle est la limite maximale au nombre d'alias d'une note ?
|
|||||||
Certain⋅es parlent d'une dizaine d'alias par note.
|
Certain⋅es parlent d'une dizaine d'alias par note.
|
||||||
|
|
||||||
Sois conscient⋅e qu'ajouter des alias ne peut qu'augmenter la probabilité de
|
Sois conscient⋅e qu'ajouter des alias ne peut qu'augmenter la probabilité de
|
||||||
collisions avec une autre note, et peut aussi retarder la livraison de ta
|
collisions avec une autre note, et peut aussi retarder la livraison de ta commande
|
||||||
commande lors d'un perm bouffe.
|
lors d'une perm bouffe.
|
||||||
|
|
||||||
|
|
||||||
Je suis trésorièr⋅e d'un club, qu'ai-je le droit de faire ?
|
Je suis trésorièr⋅e d'un club, qu'ai-je le droit de faire ?
|
||||||
-----------------------------------------------------------
|
-----------------------------------------------------------
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Être trésorièr⋅e d'un club donne la responsabilité de gérer la trésorerie du
|
Être trésorièr⋅e d'un club donne la responsabilité de gérer la trésorerie du club, et
|
||||||
club, et donc de gérer sa note. Vous obtenez donc le droit d'effectuer
|
donc de gérer sa note. Vous obtenez donc le droit d'effectuer n'importe quelle
|
||||||
n'importe quelle transaction via la note en provenance ou à destination de
|
transaction via la note en provenance ou à destination de la note de votre club. Vous
|
||||||
la note de votre club. Vous pouvez également gérer les adhésions de votre club,
|
pouvez également gérer les adhésions de votre club, en permettant à n'importe quel⋅le
|
||||||
en permettant à n'importe quel⋅le adhérent⋅e BDE de rejoindre votre club, en
|
adhérent⋅e BDE de rejoindre votre club, en prélevant d'éventuels frais d'adhésion. Les
|
||||||
prélevant d'éventuels frais d'adhésion. Les paramètres du club peuvent être
|
paramètres du club peuvent être également modifiés.
|
||||||
également modifiés.
|
|
||||||
|
|
||||||
.. danger::
|
.. danger::
|
||||||
Avoir des droits sur la Note Kfet ne signifie pas que vous devez les utiliser.
|
Avoir des droits sur la Note Kfet ne signifie pas que vous devez les utiliser. Chaque
|
||||||
Chaque opération nécessitant des droits doit être fait pour une bonne raison,
|
opération nécessitant des droits doit être fait pour une bonne raison, et doit avoir
|
||||||
et doit avoir un lien avec votre club. Vous n'avez par exemple pas le droit
|
un lien avec votre club. Vous n'avez par exemple pas le droit d'aller récupérer des
|
||||||
d'aller récupérer des informations personnelles d'adhérent⋅es pour une raison
|
informations personnelles d'adhérent⋅es pour une raison personnelle. En revanche,
|
||||||
personnelle. En revanche, faire le lien entre nom/prénom et nom de note est
|
faire le lien entre nom/prénom et nom de note est bien sûr permis pour faciliter des
|
||||||
bien sûr permis pour faciliter des transferts. Tout abus de droits constaté
|
transferts. Tout abus de droits constaté pourra mener à des sanctions prises par le
|
||||||
pourra mener à des sanctions prises par le bureau du BDE.
|
bureau du BDE.
|
||||||
|
|
||||||
|
|
||||||
Je suis trésorièr⋅e d'un club, je n'arrive pas à voir le solde du club / faire des transactions
|
Je suis trésorièr⋅e d'un club, je n'arrive pas à voir le solde du club / faire des transactions
|
||||||
-----------------------------------------------------------------------------------------------------
|
-----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
As-tu bien vérifié que tu t'es connecté⋅e initialement avec tous tes droits ?
|
As-tu bien vérifié que tu t'es connecté⋅e initialement avec tous tes droits ? Sinon,
|
||||||
Sinon, si tes droits sont tout récents, tu dois te déconnecter et te reconnecter
|
si tes droits sont tout récents, tu dois te déconnecter et te reconnecter pour que tes
|
||||||
pour que tes droits soient bien pris en compte.
|
droits soient bien pris en compte.
|
||||||
|
|
||||||
La Note permet de se connecter avec différents filtres de permission afin de
|
La Note permet de se connecter avec différents filtres de permission afin de pouvoir
|
||||||
pouvoir prêter son ordinateur avec une session ouverte pour faire quelques
|
prêter son ordinateur avec une session ouverte pour faire quelques opérations en
|
||||||
opérations en empêchant l'accès à des opérations trop sensibles.
|
empêchant l'accès à des opérations trop sensibles.
|
||||||
|
|
||||||
|
|
||||||
Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ?
|
Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ?
|
||||||
@ -104,15 +102,14 @@ Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ?
|
|||||||
.. note::
|
.. note::
|
||||||
Oui bien sûr ! Tant qu'il redirige bien vers la note de ton club.
|
Oui bien sûr ! Tant qu'il redirige bien vers la note de ton club.
|
||||||
|
|
||||||
Pour cela, rends-toi à la page `</note/buttons/>`_ pour afficher la liste des
|
Pour cela, rends-toi à la page `</note/buttons/>`_ pour afficher la liste des boutons,
|
||||||
boutons, puis tu auras accès à l'interface pour créer un bouton. Une fois le
|
puis tu auras accès à l'interface pour créer un bouton. Une fois le bouton créé, il
|
||||||
bouton créé, il apparaîtra dans l'onglet ``Consommations``.
|
apparaîtra dans l'onglet ``Consommations``.
|
||||||
|
|
||||||
Il faut noter que tant qu'il n'y a pas de boutons visibles pour ton club, tu
|
Il faut noter que tant qu'il n'y a pas de boutons visibles pour ton club, tu n'auras
|
||||||
n'auras pas accès à l'interface de consommations, et tu devras nécessairement
|
pas accès à l'interface de consommations, et tu devras nécessairement cliquer sur le
|
||||||
cliquer sur le lien ci-dessus pour accéder à l'interface d'édition des boutons.
|
lien ci-dessus pour accéder à l'interface d'édition des boutons. Une fois qu'un bouton
|
||||||
Une fois qu'un bouton pour ton club est visible, l'interface consommations
|
pour ton club est visible, l'interface consommations devient accessible.
|
||||||
devient accessible.
|
|
||||||
|
|
||||||
|
|
||||||
Après passation, je suis trésorièr⋅e d'un club. Comment récupérer mes droits note ?
|
Après passation, je suis trésorièr⋅e d'un club. Comment récupérer mes droits note ?
|
||||||
@ -120,8 +117,8 @@ Après passation, je suis trésorièr⋅e d'un club. Comment récupérer mes dro
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Tu dois pour cela contacter les trésorièr⋅es BDE (voir ci-dessous). Iels vous
|
Tu dois pour cela contacter les trésorièr⋅es BDE (voir ci-dessous). Iels vous
|
||||||
expliqueront en détails vos droits et vos interdits et vous donneront les
|
expliqueront en détails vos droits et vos interdits et vous donneront les droits
|
||||||
droits requis.
|
requis.
|
||||||
|
|
||||||
|
|
||||||
Je souhaite contacter un⋅e trésorièr⋅e
|
Je souhaite contacter un⋅e trésorièr⋅e
|
||||||
@ -129,18 +126,18 @@ Je souhaite contacter un⋅e trésorièr⋅e
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Pour contacter un⋅e trésorièr⋅e, il te suffit d'envoyer un mail à l'adresse
|
Pour contacter un⋅e trésorièr⋅e, il te suffit d'envoyer un mail à l'adresse
|
||||||
`tresorerie.bde@lists.crans.org <tresorerie.bde@lists.crans.org>`_. Pense bien
|
`tresorerie.bde@lists.crans.org <tresorerie.bde@lists.crans.org>`_. Pense bien à
|
||||||
à donner ton nom de note, voire à envoyer un scan de ta carte d'identité si ta
|
donner ton nom de note, voire à envoyer un scan de ta carte d'identité si ta demande
|
||||||
demande concerne un virement entre le compte du BDE et ton propre compte.
|
concerne un virement entre le compte du BDE et ton propre compte.
|
||||||
|
|
||||||
|
|
||||||
J'ai trouvé un bug, comment le signaler ?
|
J'ai trouvé un bug, comment le signaler ?
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
La Note Kfet est développée bénévolement par des membres du BDE. Nous mettons
|
La Note Kfet est développée bénévolement par des membres du BDE. Nous mettons tous nos
|
||||||
tous nos efforts pour fournir une plateforme sans erreur et la plus ergonomique
|
efforts pour fournir une plateforme sans erreur et la plus ergonomique possible.
|
||||||
possible. Toutefois, il n'est évidemment pas exclu que des bugs soient présents :)
|
Toutefois, il n'est évidemment pas exclu que des bugs soient présents :)
|
||||||
|
|
||||||
Pour nous soumettre un bug, tu peux envoyer un mail à
|
Pour nous soumettre un bug, tu peux envoyer un mail à
|
||||||
`notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_
|
`notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_
|
||||||
@ -157,13 +154,13 @@ Je souhaite contribuer
|
|||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
La Note Kfet est essentiellement développée par des responsables informatiques du
|
La Note Kfet est essentiellement développée par des responsables informatiques du BDE
|
||||||
BDE de l'ENS Paris-Saclay. Toutefois, si vous souhaitez contribuer, vous pouvez
|
de l'ENS Paris-Saclay. Toutefois, si vous souhaitez contribuer, vous pouvez bien sûr
|
||||||
bien sûr le faire en accord avec la licence GPLv3 avec laquelle la Note Kfet est
|
le faire en accord avec la licence GPLv3 avec laquelle la Note Kfet est distribuée.
|
||||||
distribuée. Pour cela, si vous êtes adhérent⋅e Crans, vous pouvez proposer une
|
Pour cela, si vous êtes adhérent⋅e Crans, vous pouvez proposer une demande de fusion
|
||||||
demande de fusion de votre code. Si ce n'est pas le cas, vous pouvez envoyer un
|
de votre code. Si ce n'est pas le cas, vous pouvez envoyer un mail à
|
||||||
mail à `notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_.
|
`notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_. Dans les deux cas,
|
||||||
Dans les deux cas, merci de rejoindre le canal ``#note`` sur IRC :)
|
merci de rejoindre le canal ``#note`` sur IRC :)
|
||||||
|
|
||||||
|
|
||||||
Contributeur⋅rices
|
Contributeur⋅rices
|
||||||
@ -171,20 +168,22 @@ Contributeur⋅rices
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
La version 2020 de la Note Kfet a été développée sous le mandat de la
|
La version 2020 de la Note Kfet a été développée sous le mandat de la
|
||||||
Saper[list]popette. Son développement a commencé à l'été 2019. Après un mois de beta
|
Saper[list]popette. Son développement a commencé à l'été 2019. Après un mois de beta à
|
||||||
à l'été 2020, son déploiement en production s'est fait le samedi 5 septembre 2020.
|
l'été 2020, son déploiement en production s'est fait le samedi 5 septembre 2020.
|
||||||
|
|
||||||
Elle succède à presque 6 années de la
|
Elle succède à presque 6 années de la
|
||||||
`Note Kfet 2015 <https://wiki.crans.org/NoteKfet/NoteKfet2015>`_, alors en production
|
`Note Kfet 2015 <https://wiki.crans.org/NoteKfet/NoteKfet2015>`_, alors en production
|
||||||
depuis le 6 octobre 2014.
|
depuis le 6 octobre 2014.
|
||||||
|
|
||||||
Liste des contributeurs majeurs, par ordre alphabétique :
|
Liste des contributeur⋅rices majeur⋅es, par ordre alphabétique :
|
||||||
|
|
||||||
* Pierre-André « PAC » COMBY
|
* bleizi
|
||||||
* Emmy « ÿnérant » D'ANELLO
|
* erdnaxe
|
||||||
* Benjamin « esum » GRAILLOT
|
* esum
|
||||||
* Alexandre « erdnaxe » IOOSS
|
* korenst1
|
||||||
* Nicolas « nicomarg » MARGULIES
|
* nicomarg
|
||||||
|
* PAC
|
||||||
|
* ÿnérant
|
||||||
|
|
||||||
|
|
||||||
Hébergement
|
Hébergement
|
||||||
@ -192,8 +191,8 @@ Hébergement
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
En accord entre le BDE de l'ENS Paris-Saclay et le Crans, l'instance de production
|
En accord entre le BDE de l'ENS Paris-Saclay et le Crans, l'instance de production
|
||||||
présente sur `<https://note.crans.org/>`_ est hébergée sur l'un des serveurs du
|
présente sur `<https://note.crans.org/>`_ est hébergée sur l'un des serveurs du Crans.
|
||||||
Crans. Les données sont hébergées à l'adresse :
|
Les données sont hébergées à l'adresse :
|
||||||
|
|
||||||
.. code::
|
.. code::
|
||||||
|
|
||||||
|
@ -75,7 +75,7 @@ présentes sont :
|
|||||||
* Département
|
* Département
|
||||||
* Promotion
|
* Promotion
|
||||||
* Adresse
|
* Adresse
|
||||||
* Élève/étudiant
|
* Élève/étudiant⋅e
|
||||||
* Inscription aux listes de diffusion du BDE, du BDA et du BDS
|
* Inscription aux listes de diffusion du BDE, du BDA et du BDS
|
||||||
|
|
||||||
Les trois premières informations sont obligatoires pour pouvoir vous contacter
|
Les trois premières informations sont obligatoires pour pouvoir vous contacter
|
||||||
|
@ -82,7 +82,7 @@ Pour cela, on peut simplement faire :
|
|||||||
$ source env/bin/activate
|
$ source env/bin/activate
|
||||||
(env) $
|
(env) $
|
||||||
|
|
||||||
À noter que ``source`` peut s'abbréger par ``.`` uniquement.
|
À noter que ``source`` peut s'abréger par ``.`` uniquement.
|
||||||
|
|
||||||
Vous êtes donc dans un environnement virtuel Python. Pour installer les dépendances
|
Vous êtes donc dans un environnement virtuel Python. Pour installer les dépendances
|
||||||
de la note :
|
de la note :
|
||||||
|
@ -221,7 +221,7 @@ Avec l'option ``--SUPER, -S``, la personne avec ce pseudo devient super-utilisat
|
|||||||
et obtiens donc les pleins pouvoirs sur la note. À ne donner qu'aux respos info.
|
et obtiens donc les pleins pouvoirs sur la note. À ne donner qu'aux respos info.
|
||||||
|
|
||||||
Avec l'option ``--STAFF, -s``, la personne avec ce pseudo acquiert le statut équipe,
|
Avec l'option ``--STAFF, -s``, la personne avec ce pseudo acquiert le statut équipe,
|
||||||
et obtiens l'accès à django-admin. À ne donner qu'aux respos info.
|
et obtient l'accès à django-admin. À ne donner qu'aux respos info.
|
||||||
|
|
||||||
|
|
||||||
Rafraîchissement des activités
|
Rafraîchissement des activités
|
||||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user