mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-09-29 04:43:34 +02:00
Compare commits
135 Commits
196ac12c62
...
small_feat
Author | SHA1 | Date | |
---|---|---|---|
|
5af69f719d | ||
|
4f6b1d5b6c | ||
|
27a1f36183 | ||
|
83c8b9a3d0 | ||
|
0962a3735e | ||
|
9907cfbd86 | ||
|
ad90887691 | ||
|
47d2476b51 | ||
|
5d8720cf46 | ||
|
8700144dea | ||
|
d17ab26f2f | ||
|
297f289d7e | ||
|
034ad9a4ce | ||
|
897d37f74d | ||
|
42fb0aa2d6 | ||
|
4bc43ec3cb | ||
|
00737da69f | ||
|
6eb192b823 | ||
|
0934b8fa34 | ||
|
bcd6444ff2 | ||
|
2a638e7b32 | ||
|
7633c9ab4b | ||
|
bb06206a9b | ||
|
55be3c9836 | ||
|
2ac19ab7be | ||
|
7d359dec13 | ||
|
1015a5dba1 | ||
|
8f9f650826 | ||
|
99a90867cc | ||
|
0d69695b00 | ||
|
92f6d11cb5 | ||
|
1fdb30d7d2 | ||
|
6975ed6df6 | ||
|
4da87872bd | ||
|
c25f6ca2c1 | ||
|
4d567cdcc7 | ||
|
61057b71ba | ||
|
80f28aa771 | ||
|
f13a44702a | ||
|
85b857976a | ||
|
0c259155a8 | ||
|
4a9b7c1312 | ||
|
74f9c53c18 | ||
|
b10b2fb3b6 | ||
|
4fa8ef4b56 | ||
|
68e5f280b4 | ||
|
251bb933da | ||
|
4fbbfd2365 | ||
|
0ac719b1f6 | ||
|
e55a6ae407 | ||
|
59a502d624 | ||
|
312ab6dac4 | ||
|
cf53b480db | ||
|
d1aa1edd09 | ||
|
d6f9a9c5b0 | ||
|
fc0071144e | ||
|
573f2d8a22 | ||
|
da30382f41 | ||
|
8e98d62b69 | ||
|
3b7f8b87c4 | ||
|
023fc1db84 | ||
|
d50bb2134a | ||
|
0992a8a7ee | ||
|
97597eb103 | ||
|
bfa5734d55 | ||
|
296d021d54 | ||
|
6e348b995b | ||
|
12477b33cb | ||
|
adc925e4b1 | ||
|
8c3ae338ea | ||
|
c66cc14576 | ||
|
db4d0dd83a | ||
|
2af671d61a | ||
|
4c3b714b56 | ||
|
1274315cde | ||
|
4975c1ab6f | ||
|
61999a31a5 | ||
|
b217f7ceec | ||
|
2755a5f7ab | ||
|
9ab4df94e6 | ||
|
edb6abfff5 | ||
|
ea8fcad8b5 | ||
|
03c1bb41b6 | ||
|
9e700fd3de | ||
|
67b936ae98 | ||
|
ac56700705 | ||
|
f64138605d | ||
|
40922843f8 | ||
|
57f43a8700 | ||
|
a72572ded6 | ||
|
e6839a1079 | ||
|
ab9abc8520 | ||
|
249b797d5a | ||
|
65dd42fc97 | ||
|
3ebadf34bc | ||
|
f03c13a4b8 | ||
|
b1fa1c2cdd | ||
|
a273dc3eef | ||
|
852651d126 | ||
|
3af35dc0fc | ||
|
4380414c6b | ||
|
a94c937c6a | ||
|
0a261e6ad5 | ||
|
ab9329f62b | ||
|
b97b79e2ea | ||
|
483ea26f02 | ||
|
695ce63e08 | ||
|
79f50c27f1 | ||
|
5989721bc9 | ||
|
bcc3e7cc53 | ||
|
608804db30 | ||
|
6f4fbecdd0 | ||
|
82a06c29dd | ||
|
cf9d208586 | ||
|
432f50e49a | ||
|
c7bd733911 | ||
|
883589e08c | ||
|
f6ad6197de | ||
|
c36f8c25a2 | ||
|
8783a63d7f | ||
|
4cc43fe4b6 | ||
|
b7c0986a5f | ||
|
85ea43a7cf | ||
|
f54dd30482 | ||
|
6c7d86185a | ||
|
7eafe33945 | ||
|
6edef619aa | ||
|
8a1f30ebe2 | ||
|
b2c6b0e85d | ||
|
1567bc6ce5 | ||
|
c411197af3 | ||
|
bc517f02e5 | ||
|
e83ee8015f | ||
|
c26534b6b7 | ||
|
cdc6f0a3f8 |
@@ -21,3 +21,6 @@ EMAIL_PASSWORD=CHANGE_ME
|
|||||||
# Wiki configuration
|
# Wiki configuration
|
||||||
WIKI_USER=NoteKfet2020
|
WIKI_USER=NoteKfet2020
|
||||||
WIKI_PASSWORD=
|
WIKI_PASSWORD=
|
||||||
|
|
||||||
|
# OIDC
|
||||||
|
OIDC_RSA_PRIVATE_KEY=CHANGE_ME
|
||||||
|
@@ -8,7 +8,7 @@ variables:
|
|||||||
GIT_SUBMODULE_STRATEGY: recursive
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
|
||||||
# Ubuntu 22.04
|
# Ubuntu 22.04
|
||||||
py310-django42:
|
py310-django52:
|
||||||
stage: test
|
stage: test
|
||||||
image: ubuntu:22.04
|
image: ubuntu:22.04
|
||||||
before_script:
|
before_script:
|
||||||
@@ -22,10 +22,10 @@ py310-django42:
|
|||||||
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 py310-django42
|
script: tox -e py310-django52
|
||||||
|
|
||||||
# Debian Bookworm
|
# Debian Bookworm
|
||||||
py311-django42:
|
py311-django52:
|
||||||
stage: test
|
stage: test
|
||||||
image: debian:bookworm
|
image: debian:bookworm
|
||||||
before_script:
|
before_script:
|
||||||
@@ -37,7 +37,7 @@ py311-django42:
|
|||||||
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 py311-django42
|
script: tox -e py311-django52
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
stage: quality-assurance
|
stage: quality-assurance
|
||||||
|
@@ -61,8 +61,8 @@ Bien que cela permette de créer une instance sur toutes les distributions,
|
|||||||
6. (Optionnel) **Création d'une clé privée OpenID Connect**
|
6. (Optionnel) **Création d'une clé privée OpenID Connect**
|
||||||
|
|
||||||
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
||||||
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
|
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et copier la clé dans .env dans le champ
|
||||||
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
|
`OIDC_RSA_PRIVATE_KEY`.
|
||||||
|
|
||||||
7. Enjoy :
|
7. Enjoy :
|
||||||
|
|
||||||
@@ -237,8 +237,8 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
|
|||||||
7. **Création d'une clé privée OpenID Connect**
|
7. **Création d'une clé privée OpenID Connect**
|
||||||
|
|
||||||
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
||||||
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
|
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner le champ
|
||||||
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
|
`OIDC_RSA_PRIVATE_KEY` dans le .env (par défaut `/var/secrets/oidc.key`).
|
||||||
|
|
||||||
8. *Enjoy \o/*
|
8. *Enjoy \o/*
|
||||||
|
|
||||||
|
@@ -32,7 +32,7 @@ class ActivityForm(forms.ModelForm):
|
|||||||
def clean_organizer(self):
|
def clean_organizer(self):
|
||||||
organizer = self.cleaned_data['organizer']
|
organizer = self.cleaned_data['organizer']
|
||||||
if not organizer.note.is_active:
|
if not organizer.note.is_active:
|
||||||
self.add_error('organiser', _('The note of this club is inactive.'))
|
self.add_error('organizer', _('The note of this club is inactive.'))
|
||||||
return organizer
|
return organizer
|
||||||
|
|
||||||
def clean_date_end(self):
|
def clean_date_end(self):
|
||||||
|
@@ -37,6 +37,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<div id="guests_table">
|
<div id="guests_table">
|
||||||
{% render_table guests %}
|
{% render_table guests %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'">
|
||||||
|
{% trans "Export to CSV" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base_search.html" %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
@@ -44,6 +44,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<h3 class="card-header text-center">
|
<h3 class="card-header text-center">
|
||||||
{% trans "All activities" %}
|
{% trans "All activities" %}
|
||||||
</h3>
|
</h3>
|
||||||
{% render_table table %}
|
{% render_table all %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
{% comment %}
|
{% comment %}
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% load i18n perms pretty_money %}
|
{% load i18n perms pretty_money dict_get %}
|
||||||
{% url 'activity:activity_detail' activity.pk as activity_detail_url %}
|
{% url 'activity:activity_detail' activity.pk as activity_detail_url %}
|
||||||
|
|
||||||
<div id="activity_info" class="card bg-light shadow mb-3">
|
<div id="activity_info" class="card bg-light shadow mb-3">
|
||||||
@@ -53,6 +53,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ activity.open|yesno }}</dd>
|
<dd class="col-xl-6">{{ activity.open|yesno }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
{% if show_entries|dict_get:activity %}
|
||||||
|
<h2 class="text-center">
|
||||||
|
{{ entries_count|dict_get:activity }}
|
||||||
|
{% if entries_count|dict_get:activity >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %}
|
||||||
|
</h2>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer text-center">
|
<div class="card-footer text-center">
|
||||||
|
0
apps/activity/templatetags/__init__.py
Normal file
0
apps/activity/templatetags/__init__.py
Normal file
12
apps/activity/templatetags/dict_get.py
Normal file
12
apps/activity/templatetags/dict_get.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
|
||||||
|
def dict_get(d, key):
|
||||||
|
return d.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
register.filter('dict_get', dict_get)
|
@@ -67,32 +67,65 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
|
|||||||
tables = [
|
tables = [
|
||||||
lambda data: ActivityTable(data, prefix="all-"),
|
lambda data: ActivityTable(data, prefix="all-"),
|
||||||
lambda data: ActivityTable(data, prefix="upcoming-"),
|
lambda data: ActivityTable(data, prefix="upcoming-"),
|
||||||
|
lambda data: ActivityTable(data, prefix="search-"),
|
||||||
]
|
]
|
||||||
extra_context = {"title": _("Activities")}
|
extra_context = {"title": _("Activities")}
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
return super().get_queryset(**kwargs).distinct()
|
"""
|
||||||
|
Filter the user list with the given pattern.
|
||||||
|
"""
|
||||||
|
return super().get_queryset().distinct()
|
||||||
|
|
||||||
def get_tables_data(self):
|
def get_tables_data(self):
|
||||||
# first table = all activities, second table = upcoming
|
# first table = all activities, second table = upcoming, third table = search
|
||||||
|
|
||||||
|
# table search
|
||||||
|
qs = self.get_queryset().order_by('-date_start')
|
||||||
|
if "search" in self.request.GET and self.request.GET['search']:
|
||||||
|
pattern = self.request.GET['search']
|
||||||
|
|
||||||
|
# check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||||
|
prefix = '^' if valid_regex else ''
|
||||||
|
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
|
||||||
|
| Q(**{f'organizer__name{suffix}': prefix + pattern})
|
||||||
|
| Q(**{f'organizer__note__alias__name{suffix}': prefix + pattern}))
|
||||||
|
else:
|
||||||
|
qs = qs.none()
|
||||||
|
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Activity, 'view'))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
self.get_queryset().order_by("-date_start"),
|
self.get_queryset().order_by("-date_start"),
|
||||||
Activity.objects.filter(date_end__gt=timezone.now())
|
Activity.objects.filter(date_end__gt=timezone.now())
|
||||||
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
|
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
|
||||||
.distinct()
|
.distinct()
|
||||||
.order_by("date_start")
|
.order_by("date_start"),
|
||||||
|
search_table,
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
tables = context["tables"]
|
tables = context["tables"]
|
||||||
for name, table in zip(["table", "upcoming"], tables):
|
for name, table in zip(["all", "upcoming", "table"], tables):
|
||||||
context[name] = table
|
context[name] = table
|
||||||
|
|
||||||
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
|
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
|
||||||
context["started_activities"] = started_activities
|
context["started_activities"] = started_activities
|
||||||
|
|
||||||
|
entries_count = {}
|
||||||
|
show_entries = {}
|
||||||
|
for activity in started_activities:
|
||||||
|
if activity.activity_type.manage_entries:
|
||||||
|
entries = Entry.objects.filter(activity=activity)
|
||||||
|
entries_count[activity] = entries.count()
|
||||||
|
|
||||||
|
show_entries[activity] = True
|
||||||
|
context["entries_count"] = entries_count
|
||||||
|
context["show_entries"] = show_entries
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -103,12 +136,19 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
|
|||||||
model = Activity
|
model = Activity
|
||||||
context_object_name = "activity"
|
context_object_name = "activity"
|
||||||
extra_context = {"title": _("Activity detail")}
|
extra_context = {"title": _("Activity detail")}
|
||||||
|
export_formats = ["csv"]
|
||||||
|
|
||||||
tables = [
|
tables = [
|
||||||
lambda data: GuestTable(data, prefix="guests-"),
|
GuestTable,
|
||||||
lambda data: OpenerTable(data, prefix="opener-"),
|
OpenerTable,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_tables(self):
|
||||||
|
tables = super().get_tables()
|
||||||
|
tables[0].prefix = "guests"
|
||||||
|
tables[1].prefix = "opener"
|
||||||
|
return tables
|
||||||
|
|
||||||
def get_tables_data(self):
|
def get_tables_data(self):
|
||||||
return [
|
return [
|
||||||
Guest.objects.filter(activity=self.object)
|
Guest.objects.filter(activity=self.object)
|
||||||
@@ -117,6 +157,51 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
|
|||||||
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
|
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def render_to_response(self, context, **response_kwargs):
|
||||||
|
"""
|
||||||
|
Gère l'export CSV manuel pour MultiTableMixin.
|
||||||
|
"""
|
||||||
|
if "_export" in self.request.GET:
|
||||||
|
import tablib
|
||||||
|
table_name = self.request.GET.get("table")
|
||||||
|
if table_name:
|
||||||
|
tables = self.get_tables()
|
||||||
|
data_list = self.get_tables_data()
|
||||||
|
|
||||||
|
for t, d in zip(tables, data_list):
|
||||||
|
if t.prefix == table_name:
|
||||||
|
# Préparer le CSV
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
columns = list(t.base_columns) # noms des colonnes
|
||||||
|
dataset.headers = columns
|
||||||
|
|
||||||
|
for row in d:
|
||||||
|
values = []
|
||||||
|
for col in columns:
|
||||||
|
try:
|
||||||
|
val = getattr(row, col, "")
|
||||||
|
# Gestion spéciale pour la colonne 'entry'
|
||||||
|
if col == "entry":
|
||||||
|
if getattr(row, "has_entry", False):
|
||||||
|
val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
else:
|
||||||
|
val = ""
|
||||||
|
values.append(str(val) if val is not None else "")
|
||||||
|
except Exception: # RelatedObjectDoesNotExist ou autre
|
||||||
|
values.append("")
|
||||||
|
dataset.append(values)
|
||||||
|
|
||||||
|
csv_bytes = dataset.export("csv")
|
||||||
|
if isinstance(csv_bytes, str):
|
||||||
|
csv_bytes = csv_bytes.encode("utf-8")
|
||||||
|
|
||||||
|
response = HttpResponse(csv_bytes, content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Sinon rendu normal
|
||||||
|
return super().render_to_response(context, **response_kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
|
|
||||||
@@ -137,6 +222,14 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
|
|||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.object.activity_type.manage_entries:
|
||||||
|
entries = Entry.objects.filter(activity=self.object)
|
||||||
|
context["entries_count"] = {self.object: entries.count()}
|
||||||
|
|
||||||
|
context["show_entries"] = {self.object: timezone.now() > timezone.localtime(self.object.date_start)}
|
||||||
|
else:
|
||||||
|
context["entries_count"] = {self.object: 0}
|
||||||
|
context["show_entries"] = {self.object: False}
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@@ -19,6 +19,10 @@ if "activity" in settings.INSTALLED_APPS:
|
|||||||
from activity.api.urls import register_activity_urls
|
from activity.api.urls import register_activity_urls
|
||||||
register_activity_urls(router, 'activity')
|
register_activity_urls(router, 'activity')
|
||||||
|
|
||||||
|
if "family" in settings.INSTALLED_APPS:
|
||||||
|
from family.api.urls import register_family_urls
|
||||||
|
register_family_urls(router, 'family')
|
||||||
|
|
||||||
if "food" in settings.INSTALLED_APPS:
|
if "food" in settings.INSTALLED_APPS:
|
||||||
from food.api.urls import register_food_urls
|
from food.api.urls import register_food_urls
|
||||||
register_food_urls(router, 'food')
|
register_food_urls(router, 'food')
|
||||||
|
0
apps/family/__init__.py
Normal file
0
apps/family/__init__.py
Normal file
0
apps/family/api/__init__.py
Normal file
0
apps/family/api/__init__.py
Normal file
46
apps/family/api/serializers.py
Normal file
46
apps/family/api/serializers.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from ..models import Family, FamilyMembership, Challenge, Achievement
|
||||||
|
|
||||||
|
|
||||||
|
class FamilySerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Family.
|
||||||
|
The djangorestframework plugin will analyse the model `Family` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = Family
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyMembershipSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for FamilyMembership.
|
||||||
|
The djangorestframework plugin will analyse the model `FamilyMembership` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = FamilyMembership
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Challenge.
|
||||||
|
The djangorestframework plugin will analyse the model `Challenge` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = Challenge
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Achievement.
|
||||||
|
The djangorestframework plugin will analyse the model `Achievement` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = Achievement
|
||||||
|
fields = '__all__'
|
20
apps/family/api/urls.py
Normal file
20
apps/family/api/urls.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet, BatchAchievementsAPIView
|
||||||
|
|
||||||
|
|
||||||
|
def register_family_urls(router, path):
|
||||||
|
"""
|
||||||
|
Configure router for Family REST API
|
||||||
|
"""
|
||||||
|
router.register(path + '/family', FamilyViewSet)
|
||||||
|
router.register(path + '/familymembership', FamilyMembershipViewSet)
|
||||||
|
router.register(path + '/challenge', ChallengeViewSet)
|
||||||
|
router.register(path + '/achievement', AchievementViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('achievements/batch/', BatchAchievementsAPIView.as_view(), name='batch_achievements')
|
||||||
|
]
|
98
apps/family/api/views.py
Normal file
98
apps/family/api/views.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Copyright (C) 2018-2025 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 api.filters import RegexSafeSearchFilter
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from .serializers import FamilySerializer, FamilyMembershipSerializer, ChallengeSerializer, AchievementSerializer
|
||||||
|
from ..models import Family, FamilyMembership, Challenge, Achievement
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `Family` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/family/family/
|
||||||
|
"""
|
||||||
|
queryset = Family.objects.order_by('id')
|
||||||
|
serializer_class = FamilySerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
|
filterset_fields = ['name', 'description', 'score', 'rank', ]
|
||||||
|
search_fields = ['$name', '$description', ]
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyMembershipViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `FamilyMembership` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/family/familymembership/
|
||||||
|
"""
|
||||||
|
queryset = FamilyMembership.objects.order_by('id')
|
||||||
|
serializer_class = FamilyMembershipSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
|
filterset_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name',
|
||||||
|
'user__note__alias__normalized_name', 'family__name', 'family__description', 'year', ]
|
||||||
|
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', '$user__note__alias__name',
|
||||||
|
'$user__note__alias__normalized_name', '$family__name', '$family__description', '$year', ]
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `Challenge` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/family/challenge/
|
||||||
|
"""
|
||||||
|
queryset = Challenge.objects.order_by('id')
|
||||||
|
serializer_class = ChallengeSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
|
filterset_fields = ['name', 'description', 'points', ]
|
||||||
|
search_fields = ['$name', '$description', '$points', ]
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementViewSet(ReadProtectedModelViewSet):
|
||||||
|
"""
|
||||||
|
REST API View set.
|
||||||
|
The djangorestframework plugin will get all `Achievement` objects, serialize it to JSON with the given serializer,
|
||||||
|
then render it on /api/family/achievement/
|
||||||
|
"""
|
||||||
|
queryset = Achievement.objects.order_by('id')
|
||||||
|
serializer_class = AchievementSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
|
filterset_fields = ['family__name', 'family__description', 'challenge__name', 'challenge__description', 'obtained_at', 'valid', ]
|
||||||
|
search_fields = ['$family__name', '$family__description', '$challenge__name', '$challenge__description', ]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchAchievementsAPIView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request, format=None):
|
||||||
|
family_ids = request.data.get('families')
|
||||||
|
challenge_ids = request.data.get('challenges')
|
||||||
|
families = Family.objects.filter(id__in=family_ids)
|
||||||
|
challenges = Challenge.objects.filter(id__in=challenge_ids)
|
||||||
|
results = []
|
||||||
|
for family in families:
|
||||||
|
for challenge in challenges:
|
||||||
|
a, created = Achievement.objects.get_or_create(family=family, challenge=challenge)
|
||||||
|
if created:
|
||||||
|
results.append({
|
||||||
|
'family': family.name,
|
||||||
|
'challenge': challenge.name,
|
||||||
|
'status': 'created'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
'family': family.name,
|
||||||
|
'challenge': challenge.name,
|
||||||
|
'status': 'existed',
|
||||||
|
})
|
||||||
|
for family in families:
|
||||||
|
family.update_score()
|
||||||
|
Family.update_ranking()
|
||||||
|
|
||||||
|
return Response({'results': results}, status=status.HTTP_201_CREATED)
|
11
apps/family/apps.py
Normal file
11
apps/family/apps.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Copyright (C) 2018-2025 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 FamilyConfig(AppConfig):
|
||||||
|
name = 'family'
|
||||||
|
verbose_name = _('family')
|
44
apps/family/forms.py
Normal file
44
apps/family/forms.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.forms.widgets import NumberInput
|
||||||
|
from note_kfet.inputs import Autocomplete
|
||||||
|
|
||||||
|
from .models import Challenge, FamilyMembership, User, Family
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
To update a challenge
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = Challenge
|
||||||
|
fields = ('name', 'description', 'points',)
|
||||||
|
widgets = {
|
||||||
|
"points": NumberInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Family
|
||||||
|
fields = ('name', 'description', )
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyMembershipForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = FamilyMembership
|
||||||
|
fields = ('user', )
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
"user":
|
||||||
|
Autocomplete(
|
||||||
|
User,
|
||||||
|
attrs={
|
||||||
|
'api_url': '/api/user/',
|
||||||
|
'name_field': 'username',
|
||||||
|
'placeholder': 'Nom ...',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
73
apps/family/migrations/0001_initial.py
Normal file
73
apps/family/migrations/0001_initial.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 4.2.21 on 2025-07-06 16:07
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Challenge',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
('description', models.CharField(max_length=255, verbose_name='description')),
|
||||||
|
('points', models.PositiveIntegerField(verbose_name='points')),
|
||||||
|
('obtained', models.PositiveIntegerField(default=0, verbose_name='obtained')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'challenge',
|
||||||
|
'verbose_name_plural': 'challenges',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Family',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
|
||||||
|
('description', models.CharField(max_length=255, verbose_name='description')),
|
||||||
|
('score', models.PositiveIntegerField(default=0, verbose_name='score')),
|
||||||
|
('rank', models.PositiveIntegerField(verbose_name='rank')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Family',
|
||||||
|
'verbose_name_plural': 'Families',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Achievement',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('obtained_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='obtained at')),
|
||||||
|
('challenge', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.challenge')),
|
||||||
|
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.family', verbose_name='family')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'achievement',
|
||||||
|
'verbose_name_plural': 'achievements',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FamilyMembership',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('year', models.PositiveIntegerField(default=2025, verbose_name='year')),
|
||||||
|
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='members', to='family.family', verbose_name='family')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='family_memberships', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'family membership',
|
||||||
|
'verbose_name_plural': 'family memberships',
|
||||||
|
'unique_together': {('user', 'year')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
18
apps/family/migrations/0002_family_display_image.py
Normal file
18
apps/family/migrations/0002_family_display_image.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.23 on 2025-07-17 15:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('family', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='family',
|
||||||
|
name='display_image',
|
||||||
|
field=models.ImageField(default='pic/default.png', max_length=255, upload_to='pic/', verbose_name='display image'),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-21 21:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('family', '0002_family_display_image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='achievement',
|
||||||
|
name='valid',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='valid'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='familymembership',
|
||||||
|
name='family',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='family.family', verbose_name='family'),
|
||||||
|
),
|
||||||
|
]
|
17
apps/family/migrations/0004_remove_challenge_obtained.py
Normal file
17
apps/family/migrations/0004_remove_challenge_obtained.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-22 14:33
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('family', '0003_achievement_valid_alter_familymembership_family'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='challenge',
|
||||||
|
name='obtained',
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-13 20:26
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('family', '0004_remove_challenge_obtained'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='achievement',
|
||||||
|
unique_together={('challenge', 'family')},
|
||||||
|
),
|
||||||
|
]
|
0
apps/family/migrations/__init__.py
Normal file
0
apps/family/migrations/__init__.py
Normal file
207
apps/family/models.py
Normal file
207
apps/family/models.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.db import models, transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class Family(models.Model):
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('name'),
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('description'),
|
||||||
|
)
|
||||||
|
|
||||||
|
score = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('score'),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
rank = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('rank'),
|
||||||
|
)
|
||||||
|
|
||||||
|
display_image = models.ImageField(
|
||||||
|
verbose_name=_('display image'),
|
||||||
|
max_length=255,
|
||||||
|
blank=False,
|
||||||
|
null=False,
|
||||||
|
upload_to='pic/',
|
||||||
|
default='pic/default.png'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Family')
|
||||||
|
verbose_name_plural = _('Families')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse_lazy('family:family_detail', args=(self.pk,))
|
||||||
|
|
||||||
|
def update_score(self, *args, **kwargs):
|
||||||
|
challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self, achievement__valid=True)
|
||||||
|
points_sum = challenge_set.aggregate(models.Sum("points"))
|
||||||
|
self.score = points_sum["points__sum"] if points_sum["points__sum"] else 0
|
||||||
|
self.save()
|
||||||
|
self.update_ranking()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_ranking(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Update ranking when adding or removing points
|
||||||
|
"""
|
||||||
|
family_set = Family.objects.select_for_update().all().order_by("-score")
|
||||||
|
for i in range(family_set.count()):
|
||||||
|
if i == 0 or family_set[i].score != family_set[i - 1].score:
|
||||||
|
new_rank = i + 1
|
||||||
|
family = family_set[i]
|
||||||
|
family.rank = new_rank
|
||||||
|
family._force_save = True
|
||||||
|
family.save()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.rank is None:
|
||||||
|
last_family = Family.objects.order_by("rank").last()
|
||||||
|
if last_family is None or last_family.score > self.score:
|
||||||
|
self.rank = Family.objects.count() + 1
|
||||||
|
else:
|
||||||
|
self.rank = last_family.rank
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyMembership(models.Model):
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name=_('family_memberships'),
|
||||||
|
verbose_name=_('user'),
|
||||||
|
)
|
||||||
|
|
||||||
|
family = models.ForeignKey(
|
||||||
|
Family,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name=_('memberships'),
|
||||||
|
verbose_name=_('family'),
|
||||||
|
)
|
||||||
|
|
||||||
|
year = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('year'),
|
||||||
|
default=timezone.now().year,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'year',)
|
||||||
|
verbose_name = _('family membership')
|
||||||
|
verbose_name_plural = _('family memberships')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return _('Family membership of {user} to {family}').format(user=self.user.username, family=self.family.name, )
|
||||||
|
|
||||||
|
|
||||||
|
class Challenge(models.Model):
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('description'),
|
||||||
|
)
|
||||||
|
|
||||||
|
points = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('points'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def obtained(self):
|
||||||
|
achievements = Achievement.objects.filter(challenge=self, valid=True)
|
||||||
|
return achievements.count()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse_lazy('family:challenge_detail', args=(self.pk,))
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
# Update families who already obtained this challenge
|
||||||
|
achievements = Achievement.objects.filter(challenge=self)
|
||||||
|
for achievement in achievements:
|
||||||
|
achievement.save()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('challenge')
|
||||||
|
verbose_name_plural = _('challenges')
|
||||||
|
|
||||||
|
|
||||||
|
class Achievement(models.Model):
|
||||||
|
challenge = models.ForeignKey(
|
||||||
|
Challenge,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
|
||||||
|
)
|
||||||
|
family = models.ForeignKey(
|
||||||
|
Family,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
verbose_name=_('family'),
|
||||||
|
)
|
||||||
|
|
||||||
|
obtained_at = models.DateTimeField(
|
||||||
|
verbose_name=_('obtained at'),
|
||||||
|
default=timezone.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
valid = models.BooleanField(
|
||||||
|
verbose_name=_('valid'),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('challenge', 'family',)
|
||||||
|
verbose_name = _('achievement')
|
||||||
|
verbose_name_plural = _('achievements')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, )
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, update_score=True, **kwargs):
|
||||||
|
"""
|
||||||
|
When saving, also grants points to the family
|
||||||
|
"""
|
||||||
|
self.family = Family.objects.select_for_update().get(pk=self.family_id)
|
||||||
|
self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
if update_score:
|
||||||
|
self.family.refresh_from_db()
|
||||||
|
self.family.update_score()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
When deleting, also removes points from the family
|
||||||
|
"""
|
||||||
|
# Get the family and challenge before deletion
|
||||||
|
self.family = Family.objects.select_for_update().get(pk=self.family_id)
|
||||||
|
|
||||||
|
# Delete the achievement
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
# Remove points from the family
|
||||||
|
self.family.refresh_from_db()
|
||||||
|
self.family.update_score()
|
BIN
apps/family/static/family/img/default_picture.png
Normal file
BIN
apps/family/static/family/img/default_picture.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.0 KiB |
411
apps/family/static/family/js/achievements.js
Normal file
411
apps/family/static/family/js/achievements.js
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
// Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// When a transaction is performed, lock the interface to prevent spam clicks.
|
||||||
|
var LOCK = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the history table on the consumptions page.
|
||||||
|
*/
|
||||||
|
function refreshHistory () {
|
||||||
|
$('#history').load('/family/manage/ #history')
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
// If hash of a category in the URL, then select this category
|
||||||
|
// else select the first one
|
||||||
|
if (location.hash) {
|
||||||
|
$("a[href='" + location.hash + "']").tab('show')
|
||||||
|
} else {
|
||||||
|
$("a[data-toggle='tab']").first().tab('show')
|
||||||
|
}
|
||||||
|
|
||||||
|
// When selecting a category, change URL
|
||||||
|
$(document.body).on('click', "a[data-toggle='tab']", function () {
|
||||||
|
location.hash = this.getAttribute('href')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
notes_display = []
|
||||||
|
buttons = []
|
||||||
|
|
||||||
|
// When the user searches an alias, we update the auto-completion
|
||||||
|
autoCompleteFamily('note', 'note_list', notes, notes_display,
|
||||||
|
'note', 'user_note', 'profile_pic', function () {
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a transaction from a button.
|
||||||
|
* @param fam Where the money goes
|
||||||
|
* @param amount The price of the item
|
||||||
|
* @param type The type of the transaction (content type id for RecurrentTransaction)
|
||||||
|
* @param category_id The category identifier
|
||||||
|
* @param category_name The category name
|
||||||
|
* @param template_id The identifier of the button
|
||||||
|
* @param template_name The name of the button
|
||||||
|
*/
|
||||||
|
function addChallenge (id, name, amount) {
|
||||||
|
var challenge = null
|
||||||
|
/** Ajout de 1 à chaque clic d'un bouton déjà choisi */
|
||||||
|
buttons.forEach(function (b) {
|
||||||
|
if (b.id === id) {
|
||||||
|
challenge = b
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (challenge == null) {
|
||||||
|
challenge = {
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
}
|
||||||
|
buttons.push(challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dc_obj = true
|
||||||
|
|
||||||
|
const list = 'consos_list'
|
||||||
|
let html = ''
|
||||||
|
buttons.forEach(function (challenge) {
|
||||||
|
html += li('conso_button_' + challenge.id, challenge.name)
|
||||||
|
})
|
||||||
|
document.getElementById(list).innerHTML = html
|
||||||
|
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => {
|
||||||
|
if (LOCK) { return }
|
||||||
|
removeNote(button, 'conso_button', buttons, list)()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the page as its initial state.
|
||||||
|
*/
|
||||||
|
function reset () {
|
||||||
|
notes_display.length = 0
|
||||||
|
notes.length = 0
|
||||||
|
buttons.length = 0
|
||||||
|
document.getElementById('note_list').innerHTML = ''
|
||||||
|
document.getElementById('consos_list').innerHTML = ''
|
||||||
|
document.getElementById('note').value = ''
|
||||||
|
document.getElementById('note').dataset.originTitle = ''
|
||||||
|
$('#note').tooltip('hide')
|
||||||
|
document.getElementById('profile_pic').src = '/static/member/img/default_picture.png'
|
||||||
|
document.getElementById('profile_pic_link').href = '#'
|
||||||
|
refreshHistory()
|
||||||
|
LOCK = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all transactions: all notes in `notes` buy each item in `buttons`
|
||||||
|
*/
|
||||||
|
function consumeAll () {
|
||||||
|
if (LOCK) { return }
|
||||||
|
LOCK = true
|
||||||
|
|
||||||
|
let error = false
|
||||||
|
|
||||||
|
if (notes_display.length === 0) {
|
||||||
|
// ... gestion erreur ...
|
||||||
|
error = true
|
||||||
|
}
|
||||||
|
if (buttons.length === 0) {
|
||||||
|
// ... gestion erreur ...
|
||||||
|
error = true
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
LOCK = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Récupérer les IDs des familles et des challenges
|
||||||
|
const family_ids = notes_display.map(fam => fam.id)
|
||||||
|
const challenge_ids = buttons.map(chal => chal.id)
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/family/api/family/achievements/batch/',
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({
|
||||||
|
families: family_ids,
|
||||||
|
challenges: challenge_ids
|
||||||
|
}),
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': CSRF_TOKEN
|
||||||
|
},
|
||||||
|
success: function (data) {
|
||||||
|
reset()
|
||||||
|
data.results.forEach(function (result) {
|
||||||
|
if (result.status === 'created') {
|
||||||
|
addMsg(
|
||||||
|
interpolate(gettext('Invalid achievement for challenge %s ' +
|
||||||
|
'and family %s created.'), [result.challenge, result.family]),
|
||||||
|
'success',
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
addMsg(
|
||||||
|
interpolate(gettext('An achievement for challenge %s ' +
|
||||||
|
'and family %s already exists.'), [result.challenge, result.family]),
|
||||||
|
'danger',
|
||||||
|
8000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var searchbar = document.getElementById("search-input")
|
||||||
|
var search_results = document.getElementById("search-results")
|
||||||
|
|
||||||
|
var old_pattern = null;
|
||||||
|
var firstMatch = null;
|
||||||
|
/**
|
||||||
|
* Updates the button search tab
|
||||||
|
* @param force Forces the update even if the pattern didn't change
|
||||||
|
*/
|
||||||
|
function updateSearch(force = false) {
|
||||||
|
let pattern = searchbar.value
|
||||||
|
if (pattern === "")
|
||||||
|
firstMatch = null;
|
||||||
|
if ((pattern === old_pattern || pattern === "") && !force)
|
||||||
|
return;
|
||||||
|
firstMatch = null;
|
||||||
|
const re = new RegExp(pattern, "i");
|
||||||
|
Array.from(search_results.children).forEach(function(b) {
|
||||||
|
if (re.test(b.innerText)) {
|
||||||
|
b.hidden = false;
|
||||||
|
if (firstMatch === null) {
|
||||||
|
firstMatch = b;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
b.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchbar.addEventListener("input", function (e) {
|
||||||
|
debounce(updateSearch)()
|
||||||
|
});
|
||||||
|
searchbar.addEventListener("keyup", function (e) {
|
||||||
|
if (firstMatch && e.key === "Enter")
|
||||||
|
firstMatch.click()
|
||||||
|
});
|
||||||
|
|
||||||
|
function createshiny() {
|
||||||
|
const list_btn = document.querySelectorAll('.btn-outline-dark')
|
||||||
|
const shiny_class = list_btn[Math.floor(Math.random() * list_btn.length)].classList
|
||||||
|
shiny_class.replace('btn-outline-dark', 'btn-outline-dark-shiny')
|
||||||
|
}
|
||||||
|
createshiny()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the 20 first matched notes with a given pattern
|
||||||
|
* @param pattern The pattern that is queried
|
||||||
|
* @param fun For each found note with the matched alias `alias`, fun(note, alias) is called.
|
||||||
|
*/
|
||||||
|
function getMatchedFamilies (pattern, fun) {
|
||||||
|
$.getJSON('/api/family/family/?format=json&alias=' + pattern + '&search=family', fun)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a <li> entry with a given id and text
|
||||||
|
*/
|
||||||
|
function li (id, text, extra_css) {
|
||||||
|
return '<li class="list-group-item py-1 px-2 d-flex justify-content-between align-items-center text-truncate ' +
|
||||||
|
(extra_css || '') + '"' + ' id="' + id + '">' + text + '</li>\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un champ d'auto-complétion pour rechercher une famille par son nom (version simplifiée sans alias)
|
||||||
|
* @param field_id L'identifiant du champ texte où le nom est saisi
|
||||||
|
* @param family_list_id L'identifiant du bloc div où les familles sélectionnées sont affichées
|
||||||
|
* @param families Un tableau contenant les objets famille sélectionnés
|
||||||
|
* @param families_display Un tableau contenant les infos des familles sélectionnées : [nom, id, objet famille, quantité]
|
||||||
|
* @param family_prefix Le préfixe des <li> pour les familles sélectionnées
|
||||||
|
* @param user_family_field L'identifiant du champ qui affiche la famille survolée (optionnel)
|
||||||
|
* @param profile_pic_field L'identifiant du champ qui affiche la photo de la famille survolée (optionnel)
|
||||||
|
* @param family_click Fonction appelée lors du clic sur un nom. Si elle existe et ne retourne pas true, la famille n'est pas affichée.
|
||||||
|
*/
|
||||||
|
function autoCompleteFamily(field_id, family_list_id, families, families_display, family_prefix = 'family', user_family_field = null, profile_pic_field = null, family_click = null) {
|
||||||
|
const field = $('#' + field_id)
|
||||||
|
// Configuration du tooltip
|
||||||
|
field.tooltip({
|
||||||
|
html: true,
|
||||||
|
placement: 'bottom',
|
||||||
|
title: 'Chargement...',
|
||||||
|
trigger: 'manual',
|
||||||
|
container: field.parent(),
|
||||||
|
fallbackPlacement: 'clockwise'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Masquer le tooltip lors d'un clic ailleurs
|
||||||
|
$(document).click(function (e) {
|
||||||
|
if (!e.target.id.startsWith(family_prefix)) {
|
||||||
|
field.tooltip('hide')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let old_pattern = null
|
||||||
|
|
||||||
|
// Réinitialiser la recherche au clic
|
||||||
|
field.click(function () {
|
||||||
|
field.tooltip('hide')
|
||||||
|
field.removeClass('is-invalid')
|
||||||
|
field.val('')
|
||||||
|
old_pattern = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sur "Entrée", sélectionner la première famille
|
||||||
|
field.keypress(function (event) {
|
||||||
|
if (event.originalEvent.charCode === 13 && families.length > 0) {
|
||||||
|
const li_obj = field.parent().find('ul li').first()
|
||||||
|
displayFamily(families[0], families[0].name, user_family_field, profile_pic_field)
|
||||||
|
li_obj.trigger('click')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mise à jour des suggestions lors de la saisie
|
||||||
|
field.keyup(function (e) {
|
||||||
|
field.removeClass('is-invalid')
|
||||||
|
|
||||||
|
if (e.originalEvent.charCode === 13) { return }
|
||||||
|
|
||||||
|
const pattern = field.val()
|
||||||
|
|
||||||
|
if (pattern === old_pattern) { return }
|
||||||
|
old_pattern = pattern
|
||||||
|
families.length = 0
|
||||||
|
|
||||||
|
if (pattern === '') {
|
||||||
|
field.tooltip('hide')
|
||||||
|
families.length = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appel à l'API pour récupérer les familles correspondantes
|
||||||
|
$.getJSON('/api/family/family/?format=json&search=' + pattern,
|
||||||
|
function (results) {
|
||||||
|
if (pattern !== $('#' + field_id).val()) { return }
|
||||||
|
|
||||||
|
let matched_html = '<ul class="list-group list-group-flush">'
|
||||||
|
results.results.forEach(function (family) {
|
||||||
|
matched_html += li(family_prefix + '_' + family.id,
|
||||||
|
family.name,
|
||||||
|
'')
|
||||||
|
families.push(family)
|
||||||
|
})
|
||||||
|
matched_html += '</ul>'
|
||||||
|
|
||||||
|
field.attr('data-original-title', matched_html).tooltip('show')
|
||||||
|
|
||||||
|
results.results.forEach(function (family) {
|
||||||
|
const family_obj = $('#' + family_prefix + '_' + family.id)
|
||||||
|
family_obj.hover(function () {
|
||||||
|
displayFamily(family, family.name, user_family_field, profile_pic_field)
|
||||||
|
})
|
||||||
|
family_obj.click(function () {
|
||||||
|
var disp = null
|
||||||
|
families_display.forEach(function (d) {
|
||||||
|
if (d.id === family.id) {
|
||||||
|
disp = d
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (disp == null) {
|
||||||
|
disp = {
|
||||||
|
name: family.name,
|
||||||
|
id: family.id,
|
||||||
|
family: family,
|
||||||
|
}
|
||||||
|
families_display.push(disp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family_click && !family_click()) { return }
|
||||||
|
|
||||||
|
const family_list = $('#' + family_list_id)
|
||||||
|
let html = ''
|
||||||
|
families_display.forEach(function (disp) {
|
||||||
|
html += li(family_prefix + '_' + disp.id,
|
||||||
|
disp.name,
|
||||||
|
'')
|
||||||
|
})
|
||||||
|
|
||||||
|
family_list.html(html)
|
||||||
|
field.tooltip('update')
|
||||||
|
|
||||||
|
families_display.forEach(function (disp) {
|
||||||
|
const line_obj = $('#' + family_prefix + '_' + disp.id)
|
||||||
|
line_obj.hover(function () {
|
||||||
|
displayFamily(disp.family, disp.name, user_family_field, profile_pic_field)
|
||||||
|
})
|
||||||
|
line_obj.click(removeFamily(disp, family_prefix, families_display, family_list_id, user_family_field,
|
||||||
|
profile_pic_field))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le nom et la photo d'une famille
|
||||||
|
* @param family L'objet famille à afficher
|
||||||
|
* @param user_family_field L'identifiant du champ où afficher le nom (optionnel)
|
||||||
|
* @param profile_pic_field L'identifiant du champ où afficher la photo (optionnel)
|
||||||
|
*/
|
||||||
|
function displayFamily(family, user_family_field = null, profile_pic_field = null) {
|
||||||
|
if (!family.display_image) {
|
||||||
|
family.display_image = '/static/member/img/default_picture.png'
|
||||||
|
}
|
||||||
|
if (user_family_field !== null) {
|
||||||
|
$('#' + user_family_field).removeAttr('class')
|
||||||
|
$('#' + user_family_field).text(family.name)
|
||||||
|
if (profile_pic_field != null) {
|
||||||
|
$('#' + profile_pic_field).attr('src', family.display_image)
|
||||||
|
// Si tu veux un lien vers la page famille :
|
||||||
|
$('#' + profile_pic_field + '_link').attr('href', '/family/detail/' + family.id + '/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire une famille de la liste sélectionnée.
|
||||||
|
* @param d La famille à retirer
|
||||||
|
* @param family_prefix Le préfixe des <li>
|
||||||
|
* @param families_display Le tableau des familles sélectionnées
|
||||||
|
* @param family_list_id L'id du bloc où sont affichées les familles
|
||||||
|
* @param user_family_field Champ d'affichage (optionnel)
|
||||||
|
* @param profile_pic_field Champ photo (optionnel)
|
||||||
|
* @returns une fonction compatible avec les événements jQuery
|
||||||
|
*/
|
||||||
|
function removeFamily(d, family_prefix, families_display, family_list_id, user_family_field = null, profile_pic_field = null) {
|
||||||
|
return function () {
|
||||||
|
const new_families_display = []
|
||||||
|
let html = ''
|
||||||
|
families_display.forEach(function (disp) {
|
||||||
|
})
|
||||||
|
|
||||||
|
families_display.length = 0
|
||||||
|
new_families_display.forEach(function (disp) {
|
||||||
|
families_display.push(disp)
|
||||||
|
})
|
||||||
|
|
||||||
|
$('#' + family_list_id).html(html)
|
||||||
|
families_display.forEach(function (disp) {
|
||||||
|
const obj = $('#' + family_prefix + '_' + disp.id)
|
||||||
|
obj.click(removeFamily(disp, family_prefix, families_display, family_list_id, user_family_field, profile_pic_field))
|
||||||
|
obj.hover(function () {
|
||||||
|
displayFamily(disp.family, user_family_field, profile_pic_field)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
149
apps/family/tables.py
Normal file
149
apps/family/tables.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import django_tables2 as tables
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_tables2 import A
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from note_kfet.middlewares import get_current_request
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
|
from .models import Achievement, Challenge, Family, FamilyMembership
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyTable(tables.Table):
|
||||||
|
"""
|
||||||
|
List all families
|
||||||
|
"""
|
||||||
|
|
||||||
|
description = tables.Column(verbose_name=_("Description"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
attrs = {
|
||||||
|
'class': 'table table-condensed table-striped table-hover'
|
||||||
|
}
|
||||||
|
model = Family
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
fields = ('name', 'score', 'rank',)
|
||||||
|
order_by = ('rank',)
|
||||||
|
row_attrs = {
|
||||||
|
'class': 'table-row',
|
||||||
|
'data-href': lambda record: reverse('family:family_detail', args=[record.pk]),
|
||||||
|
'style': 'cursor:pointer',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeTable(tables.Table):
|
||||||
|
"""
|
||||||
|
List all challenges
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = tables.Column(verbose_name=_("Name"))
|
||||||
|
description = tables.Column(verbose_name=_("Description"))
|
||||||
|
points = tables.Column(verbose_name=_("Points"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
attrs = {
|
||||||
|
'class': 'table table-condensed table-striped table-hover'
|
||||||
|
}
|
||||||
|
order_by = ('id',)
|
||||||
|
model = Challenge
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
fields = ('name', 'description', 'points',)
|
||||||
|
row_attrs = {
|
||||||
|
'class': 'table-row',
|
||||||
|
'data-href': lambda record: reverse('family:challenge_detail', args=[record.pk]),
|
||||||
|
'style': 'cursor:pointer',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyMembershipTable(tables.Table):
|
||||||
|
"""
|
||||||
|
List all family memberships.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_user(self, value):
|
||||||
|
# Display user's name, clickable if permission is granted
|
||||||
|
s = value.username
|
||||||
|
if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
|
||||||
|
s = format_html("<a href='{url}'>{name}</a>",
|
||||||
|
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
attrs = {
|
||||||
|
'class': 'table table-condensed table-striped',
|
||||||
|
'style': 'table-layout: fixed;'
|
||||||
|
}
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
fields = ('user',)
|
||||||
|
model = FamilyMembership
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementTable(tables.Table):
|
||||||
|
"""
|
||||||
|
List recent achievements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
challenge = tables.Column(verbose_name=_("Challenge"))
|
||||||
|
|
||||||
|
validate = tables.LinkColumn(
|
||||||
|
'family:achievement_validate',
|
||||||
|
args=[A('id')],
|
||||||
|
verbose_name=_("Validate"),
|
||||||
|
text=_("Validate"),
|
||||||
|
orderable=False,
|
||||||
|
attrs={
|
||||||
|
'th': {
|
||||||
|
'id': 'validate-achievement-header'
|
||||||
|
},
|
||||||
|
'a': {
|
||||||
|
'class': 'btn btn-success',
|
||||||
|
'data-type': 'validate-achievement'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
delete = tables.LinkColumn(
|
||||||
|
'family:achievement_delete',
|
||||||
|
args=[A('id')],
|
||||||
|
verbose_name=_("Delete"),
|
||||||
|
text=_("Delete"),
|
||||||
|
orderable=False,
|
||||||
|
attrs={
|
||||||
|
'th': {
|
||||||
|
'id': 'delete-achievement-header'
|
||||||
|
},
|
||||||
|
'a': {
|
||||||
|
'class': 'btn btn-danger',
|
||||||
|
'data-type': 'delete-achievement'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
attrs = {
|
||||||
|
'class': 'table table-condensed table-striped table-hover'
|
||||||
|
}
|
||||||
|
model = Achievement
|
||||||
|
fields = ('family', 'challenge', 'challenge__points', 'obtained_at', 'valid')
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
order_by = ('-obtained_at',)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyAchievementTable(tables.Table):
|
||||||
|
"""
|
||||||
|
Table des défis réalisés par une famille spécifique.
|
||||||
|
"""
|
||||||
|
|
||||||
|
challenge = tables.Column(verbose_name=_("Challenge"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Achievement
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
fields = ('challenge', 'challenge__points', 'obtained_at', 'valid')
|
||||||
|
attrs = {
|
||||||
|
'class': 'table table-condensed table-striped table-hover'
|
||||||
|
}
|
||||||
|
order_by = ('-obtained_at',)
|
25
apps/family/templates/family/achievement_confirm_delete.html
Normal file
25
apps/family/templates/family/achievement_confirm_delete.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h4>{% trans "Delete achievement" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% blocktrans %}Are you sure you want to delete this achievement? This action can't be undone.{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<a class="btn btn-primary" href="{% url 'family:achievement_list' %}">{% trans "Return to achievements list" %}</a>
|
||||||
|
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h4>{% trans "Validate achievement" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% blocktrans %}Are you sure you want to validate this achievement? This action can't be undone.{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<a class="btn btn-primary" href="{% url 'family:achievement_list' %}">{% trans "Return to achievements list" %}</a>
|
||||||
|
<form method="post" action="{% url 'family:achievement_validate' pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-success">{% trans "Validate" %}</button>
|
||||||
|
</form>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
33
apps/family/templates/family/achievement_list.html
Normal file
33
apps/family/templates/family/achievement_list.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n django_tables2 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="card mb-4" id="history">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold">
|
||||||
|
{% trans "Invalid achievements history" %}
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:manage" %}">
|
||||||
|
{% trans "Return to management page" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% render_table invalid %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4" id="history">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold">
|
||||||
|
{% trans "Valid achievements history" %}
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:manage" %}">
|
||||||
|
{% trans "Return to management page" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% render_table valid %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
60
apps/family/templates/family/add_member.html
Normal file
60
apps/family/templates/family/add_member.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "family/base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load crispy_forms_tags i18n pretty_money %}
|
||||||
|
|
||||||
|
{% block profile_content %}
|
||||||
|
<div class="card bg-light">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<form method="post" action="">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script>
|
||||||
|
function autocompleted(user) {
|
||||||
|
$("#id_last_name").val(user.last_name);
|
||||||
|
$("#id_first_name").val(user.first_name);
|
||||||
|
$.getJSON("/api/members/profile/" + user.id + "/", function (profile) {
|
||||||
|
let fee = profile.paid ? "{{ club.membership_fee_paid }}" : "{{ club.membership_fee_unpaid }}";
|
||||||
|
$("#id_credit_amount").val((Number(fee) / 100).toFixed(2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
soge_field = $("#id_soge");
|
||||||
|
|
||||||
|
function fillFields() {
|
||||||
|
let checked = soge_field.is(':checked');
|
||||||
|
if (!checked) {
|
||||||
|
$("input").attr('disabled', false);
|
||||||
|
$("#id_user").attr('disabled', true);
|
||||||
|
$("select").attr('disabled', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let credit_type = $("#id_credit_type");
|
||||||
|
credit_type.attr('disabled', true);
|
||||||
|
credit_type.val(4);
|
||||||
|
|
||||||
|
let credit_amount = $("#id_credit_amount");
|
||||||
|
credit_amount.attr('disabled', true);
|
||||||
|
credit_amount.val('{{ total_fee }}');
|
||||||
|
|
||||||
|
let bank = $("#id_bank");
|
||||||
|
bank.attr('disabled', true);
|
||||||
|
bank.val('Société générale');
|
||||||
|
}
|
||||||
|
|
||||||
|
soge_field.change(fillFields);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
52
apps/family/templates/family/base.html
Normal file
52
apps/family/templates/family/base.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n perms %}
|
||||||
|
|
||||||
|
{# Use a fluid-width container #}
|
||||||
|
{% block containertype %}container-fluid{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-xl-4">
|
||||||
|
{% block profile_info %}
|
||||||
|
<div class="card bg-light" id="card-infos">
|
||||||
|
<h4 class="card-header text-center">
|
||||||
|
{{ family.name }}
|
||||||
|
</h4>
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="{% url 'family:update_pic' family.pk %}">
|
||||||
|
<img src="{{ family.display_image.url }}" class="img-thumbnail mt-2">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="profile_infos">
|
||||||
|
{% include "family/family_info.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
{% if can_add_members %}
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url 'family:family_add_member' family_pk=family.pk %}"
|
||||||
|
data-turbolinks="false"> {% trans "Add member" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if ".change_"|has_perm:family %}
|
||||||
|
<a class="btn btn-sm btn-secondary" href="{% url 'family:family_update' pk=family.pk %}"
|
||||||
|
data-turbolinks="false">
|
||||||
|
<i class="fa fa-edit"></i> {% trans 'Update Profile' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% url 'family:family_detail' family.pk as family_detail_url %}
|
||||||
|
{% if request.path_info != family_detail_url %}
|
||||||
|
<a class="btn btn-sm btn-primary" href="{{ family_detail_url }}">{% trans 'View Profile' %}</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-sm btn-primary" href="{% url "family:family_list" %}">
|
||||||
|
{% trans "Return to the family list" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-8">
|
||||||
|
{% block profile_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
36
apps/family/templates/family/challenge_detail.html
Normal file
36
apps/family/templates/family/challenge_detail.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) by BDE ENS Paris-Saclay
|
||||||
|
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 }} {{ challenge.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
{% for field, value in fields %}
|
||||||
|
<li> {{ field }} : {{ value }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li> {% trans "Obtained by " %} {{obtained}}
|
||||||
|
{% if obtained > 1 %}
|
||||||
|
{% trans "families" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "family" %}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a class="btn btn-sm btn-primary" href="{% url "family:challenge_list" %}">
|
||||||
|
{% trans "Return to the challenge list" %}
|
||||||
|
</a>
|
||||||
|
{% if update %}
|
||||||
|
<a class="btn btn-sm btn-secondary" href="{% url "family:challenge_update" pk=challenge.pk %}">
|
||||||
|
{% trans "Update" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
21
apps/family/templates/family/challenge_form.html
Normal file
21
apps/family/templates/family/challenge_form.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
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 %}
|
42
apps/family/templates/family/challenge_list.html
Normal file
42
apps/family/templates/family/challenge_list.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-12">
|
||||||
|
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
|
||||||
|
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Families" %}
|
||||||
|
</a>
|
||||||
|
<a href="#" class="btn btn-sm btn-outline-primary active">
|
||||||
|
{% trans "Challenges" %}
|
||||||
|
</a>
|
||||||
|
{% if can_manage %}
|
||||||
|
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Manage" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(".table-row").click(function () {
|
||||||
|
window.document.location = $(this).data("href");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
29
apps/family/templates/family/family_detail.html
Normal file
29
apps/family/templates/family/family_detail.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "family/base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n perms %}
|
||||||
|
|
||||||
|
{% block profile_content %}
|
||||||
|
{% if member_list.data %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header position-relative" id="clubListHeading">
|
||||||
|
<i class="fa fa-users"></i> {% trans "Family members" %}
|
||||||
|
</div>
|
||||||
|
{% render_table member_list %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-4"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if achievement_list.data %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header position-relative">
|
||||||
|
<i class="fa fa-trophy"></i> {% trans "Completed challenges" %}
|
||||||
|
</div>
|
||||||
|
{% render_table achievement_list %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
21
apps/family/templates/family/family_form.html
Normal file
21
apps/family/templates/family/family_form.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
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 %}
|
15
apps/family/templates/family/family_info.html
Normal file
15
apps/family/templates/family/family_info.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% load i18n pretty_money perms %}
|
||||||
|
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-xl-6">{% trans 'name'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ family.name }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'description'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ family.description }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'score'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ family.score }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'rank'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ family.rank }}</dd>
|
||||||
|
</dl>
|
43
apps/family/templates/family/family_list.html
Normal file
43
apps/family/templates/family/family_list.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-12">
|
||||||
|
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
|
||||||
|
<a href="#" class="btn btn-sm btn-outline-primary active">
|
||||||
|
{% trans "Families" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Challenges" %}
|
||||||
|
</a>
|
||||||
|
{% if can_manage %}
|
||||||
|
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Manage" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(".table-row").click(function () {
|
||||||
|
window.document.location = $(this).data("href");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
287
apps/family/templates/family/manage.html
Normal file
287
apps/family/templates/family/manage.html
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n static django_tables2 %}
|
||||||
|
|
||||||
|
{% block containertype %}container-fluid{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-12">
|
||||||
|
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
|
||||||
|
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Families" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Challenges" %}
|
||||||
|
</a>
|
||||||
|
<a href="#" class="btn btn-sm btn-outline-primary active">
|
||||||
|
{% trans "Manage" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class='col-sm-5 col-xl-6' id="infos_div">
|
||||||
|
{% if can_add_achievement %}
|
||||||
|
<div class="row justify-content-center justify-content-md-end">
|
||||||
|
{# Family details column #}
|
||||||
|
<div class="col picture-col">
|
||||||
|
<div class="card bg-light mb-4 text-center">
|
||||||
|
<a id="profile_pic_link" href="#">
|
||||||
|
<img src="{% static "member/img/default_picture.png" %}" id="profile_pic" alt="" class="card-img-top d-none d-sm-block">
|
||||||
|
</a>
|
||||||
|
<div class="card-body text-center text-break p-2">
|
||||||
|
<span id="user_note"><i class="small">{% trans "Please select a family" %}</i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Family selection column #}
|
||||||
|
<div class="col-xl" id="user_select_div">
|
||||||
|
<div class="card bg-light border-success mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold">
|
||||||
|
{% trans "Families" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0" style="min-height:125px;">
|
||||||
|
<ul class="list-group list-group-flush" id="note_list"></ul>
|
||||||
|
</div>
|
||||||
|
{# User search with autocompletion #}
|
||||||
|
<div class="card-footer">
|
||||||
|
<input class="form-control mx-auto d-block mb-2" placeholder="{% trans "Name" %}" type="text" id="note" autofocus />
|
||||||
|
{% if user_family %}
|
||||||
|
<button class="btn btn-sm btn-secondary btn-block" id="select_my_family">
|
||||||
|
{% trans "Select my family" %} ({{ user_family.name }})
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Summary of challenges and validate button #}
|
||||||
|
<div class="col-xl-5" id="consos_list_div">
|
||||||
|
<div class="card bg-light border-info mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<p class="card-text font-weight-bold">
|
||||||
|
{% trans "Challenges" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0" style="min-height:125px;">
|
||||||
|
<ul class="list-group list-group-flush" id="consos_list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<span id="consume_all" class="btn btn-primary">
|
||||||
|
{% trans "Validate!" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Create family/challenge buttons #}
|
||||||
|
{% if can_add_family or can_add_challenge %}
|
||||||
|
<div class="card bg-light border-success mb-4">
|
||||||
|
<h3 class="card-header font-weight-bold text-center">
|
||||||
|
{% trans "Create a family or challenge" %}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
{% if can_add_family %}
|
||||||
|
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:family_create" %}">
|
||||||
|
{% trans "Add a family" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_add_challenge %}
|
||||||
|
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:challenge_create" %}">
|
||||||
|
{% trans "Add a challenge" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Buttons column #}
|
||||||
|
<div class="col">
|
||||||
|
{% if can_add_achievement %}
|
||||||
|
<div class="card bg-light border-primary text-center mb-4">
|
||||||
|
{# Tabs for list and search #}
|
||||||
|
<div class="card-header">
|
||||||
|
<ul class="nav nav-tabs nav-fill card-header-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link font-weight-bold" data-toggle="tab" href="#list">
|
||||||
|
{% trans "List" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link font-weight-bold" data-toggle="tab" href="#search">
|
||||||
|
{% trans "Search" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tabs content #}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane" id="list">
|
||||||
|
<div class="d-inline-flex flex-wrap justify-content-center">
|
||||||
|
{% for challenge in all_challenges %}
|
||||||
|
<button class="btn btn-outline-dark rounded-0 flex-fill"
|
||||||
|
id="challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
|
||||||
|
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane" id="search">
|
||||||
|
<input class="form-control mx-auto d-block mb-3" placeholder="{% trans "Search challenge..." %}" type="search" id="search-input"/>
|
||||||
|
<div class="d-inline-flex flex-wrap justify-content-center" id="search-results">
|
||||||
|
{% for challenge in all_challenges %}
|
||||||
|
<button class="btn btn-outline-dark rounded-0 flex-fill" hidden
|
||||||
|
id="search_challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
|
||||||
|
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# achievement history #}
|
||||||
|
{% if table.data %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header position-relative" id="historyListHeading">
|
||||||
|
<a class="stretched-link font-weight-bold"
|
||||||
|
href="{% url 'family:achievement_list' %}" >
|
||||||
|
{% trans "Recent achievements history" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div id="history">
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Popup de validation -->
|
||||||
|
<div class="modal fade" id="validationModal" tabindex="-1" role="dialog" aria-labelledby="validationModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content border-success">
|
||||||
|
<div class="modal-header bg-success text-white">
|
||||||
|
<h5 class="modal-title" id="validationModalLabel">{% trans "Confirmation" %}</h5>
|
||||||
|
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p><strong>{% trans "Are you sure you want to validate this challenge?" %}</strong></p>
|
||||||
|
<p>{% trans "To have your challenge officially validated, please send a message with:" %}</p>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans "The name of the family" %}</li>
|
||||||
|
<li>{% trans "The name of the challenge" %}</li>
|
||||||
|
<li>{% trans "A photo or video as proof" %}</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<strong>{% trans "Send it via WhatsApp to:" %}</strong>
|
||||||
|
{% if phone_numbers %}"
|
||||||
|
{% for num in phone_numbers %}
|
||||||
|
<a href="https://wa.me/{{ num }}" target="_blank">{{ num }}</a>{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans "OK" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script type="text/javascript" src="{% static "family/js/achievements.js" %}"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
{% for challenge in all_challenges %}
|
||||||
|
document.getElementById("challenge{{ challenge.id }}").addEventListener("click", function() {
|
||||||
|
addChallenge({{ challenge.id}}, "{{ challenge.name|escapejs }}", {{ challenge.points }});
|
||||||
|
});
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for challenge in all_challenges %}
|
||||||
|
document.getElementById("search_challenge{{ challenge.id }}").addEventListener("click", function() {
|
||||||
|
addChallenge({{ challenge.id}}, "{{ challenge.name|escapejs }}", {{ challenge.points }});
|
||||||
|
});
|
||||||
|
{% endfor %}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.getElementById("consume_all").addEventListener("click", function () {
|
||||||
|
$('#validationModal').modal('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#validationModal .btn-primary').on('click', function () {
|
||||||
|
consumeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
{% if user_family %}
|
||||||
|
document.getElementById("select_my_family").addEventListener("click", function () {
|
||||||
|
// Simulate selecting the user's family
|
||||||
|
var userFamily = {
|
||||||
|
id: {{ user_family.id }},
|
||||||
|
name: "{{ user_family.name|escapejs }}",
|
||||||
|
display_image: "{{ user_family.display_image.url|default:'/static/member/img/default_picture.png'|escapejs }}"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if family is already selected
|
||||||
|
var alreadySelected = false;
|
||||||
|
notes_display.forEach(function (d) {
|
||||||
|
if (d.id === userFamily.id) {
|
||||||
|
alreadySelected = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!alreadySelected) {
|
||||||
|
// Add the family to the selected families
|
||||||
|
var disp = {
|
||||||
|
name: userFamily.name,
|
||||||
|
id: userFamily.id,
|
||||||
|
family: userFamily,
|
||||||
|
};
|
||||||
|
notes_display.push(disp);
|
||||||
|
|
||||||
|
// Update the display
|
||||||
|
const family_list = $('#note_list');
|
||||||
|
let html = '';
|
||||||
|
notes_display.forEach(function (disp) {
|
||||||
|
html += li('note_' + disp.id, disp.name, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
family_list.html(html);
|
||||||
|
|
||||||
|
// Add click handlers for removal
|
||||||
|
notes_display.forEach(function (disp) {
|
||||||
|
const line_obj = $('#note_' + disp.id);
|
||||||
|
line_obj.hover(function () {
|
||||||
|
displayFamily(disp.family, disp.name, 'user_note', 'profile_pic');
|
||||||
|
});
|
||||||
|
line_obj.click(removeFamily(disp, 'note', notes_display, 'note_list', 'user_note', 'profile_pic'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display the family info
|
||||||
|
displayFamily(userFamily, userFamily.name, 'user_note', 'profile_pic');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
118
apps/family/templates/family/picture_update.html
Normal file
118
apps/family/templates/family/picture_update.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "family/base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block profile_content %}
|
||||||
|
<div class="card bg-light">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center">
|
||||||
|
<form method="post" enctype="multipart/form-data" id="formUpload">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form |crispy }}
|
||||||
|
{% if user.note.display_image != "pic/default.png" %}
|
||||||
|
<input type="submit" class="btn btn-primary" value="{% trans "Remove" %}">
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- MODAL TO CROP THE IMAGE -->
|
||||||
|
<div class="modal fade" id="modalCrop" data-backdrop="static">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-body-wrapper" style="width: 500px; height: 500px; padding: 16px;">
|
||||||
|
<div class="modal-body" style="width: 100%; height: 100%; padding: 0">
|
||||||
|
<img src="" id="modal-image" style="display: block; max-width: 100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="btn-group pull-left" role="group">
|
||||||
|
<button type="button" class="btn btn-default" id="js-zoom-in">
|
||||||
|
<span class="glyphicon glyphicon-zoom-in"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-default js-zoom-out">
|
||||||
|
<span class="glyphicon glyphicon-zoom-out"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Nevermind" %}</button>
|
||||||
|
<button type="button" class="btn btn-primary js-crop-and-upload">{% trans "Crop and upload" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extracss %}
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.css" rel="stylesheet">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript%}
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jquery-cropper@1.0.1/dist/jquery-cropper.min.js"></script>
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
|
||||||
|
/* SCRIPT TO OPEN THE MODAL WITH THE PREVIEW */
|
||||||
|
$("#id_image").change(function (e) {
|
||||||
|
if (this.files && this.files[0]) {
|
||||||
|
// Check the image size
|
||||||
|
if (this.files[0].size > 2*1024*1024) {
|
||||||
|
alert("Ce fichier est trop volumineux.")
|
||||||
|
} else {
|
||||||
|
// Read the selected image file
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function (e) {
|
||||||
|
$("#modal-image").attr("src", e.target.result);
|
||||||
|
$("#modalCrop").modal("show");
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(this.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* SCRIPTS TO HANDLE THE CROPPER BOX */
|
||||||
|
var $image = $("#modal-image");
|
||||||
|
var cropBoxData;
|
||||||
|
var canvasData;
|
||||||
|
$("#modalCrop").on("shown.bs.modal", function () {
|
||||||
|
$image.cropper({
|
||||||
|
viewMode: 1,
|
||||||
|
aspectRatio: 1 / 1,
|
||||||
|
minCropBoxWidth: 200,
|
||||||
|
minCropBoxHeight: 200,
|
||||||
|
ready: function () {
|
||||||
|
$image.cropper("setCanvasData", canvasData);
|
||||||
|
$image.cropper("setCropBoxData", cropBoxData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on("hidden.bs.modal", function () {
|
||||||
|
cropBoxData = $image.cropper("getCropBoxData");
|
||||||
|
canvasData = $image.cropper("getCanvasData");
|
||||||
|
$image.cropper("destroy");
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".js-zoom-in").click(function () {
|
||||||
|
$image.cropper("zoom", 0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".js-zoom-out").click(function () {
|
||||||
|
$image.cropper("zoom", -0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* SCRIPT TO COLLECT THE DATA AND POST TO THE SERVER */
|
||||||
|
$(".js-crop-and-upload").click(function () {
|
||||||
|
var cropData = $image.cropper("getData");
|
||||||
|
$("#id_x").val(cropData["x"]);
|
||||||
|
$("#id_y").val(cropData["y"]);
|
||||||
|
$("#id_height").val(cropData["height"]);
|
||||||
|
$("#id_width").val(cropData["width"]);
|
||||||
|
$("#formUpload").submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
0
apps/family/tests/__init__.py
Normal file
0
apps/family/tests/__init__.py
Normal file
328
apps/family/tests/test_family.py
Normal file
328
apps/family/tests/test_family.py
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from api.tests import TestAPI
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ..api.views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet
|
||||||
|
from ..models import Family, FamilyMembership, Challenge, Achievement
|
||||||
|
|
||||||
|
|
||||||
|
class TestFamily(TestCase):
|
||||||
|
"""
|
||||||
|
Test family
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
username='admintoto',
|
||||||
|
password='toto1234',
|
||||||
|
email='toto@example.com',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
sess = self.client.session
|
||||||
|
sess['permission_mask'] = 42
|
||||||
|
sess.save()
|
||||||
|
|
||||||
|
self.family = Family.objects.create(
|
||||||
|
name='Test family',
|
||||||
|
description='',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.challenge = Challenge.objects.create(
|
||||||
|
name='Test challenge',
|
||||||
|
description='',
|
||||||
|
points=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.achievement = Achievement.objects.create(
|
||||||
|
family=self.family,
|
||||||
|
challenge=self.challenge,
|
||||||
|
valid=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_family_list(self):
|
||||||
|
"""
|
||||||
|
Test display family list
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:family_list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_family_create(self):
|
||||||
|
"""
|
||||||
|
Test create a family
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:family_create"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("family:family_create"), data={
|
||||||
|
"name": "Family toto",
|
||||||
|
"description": "A test family",
|
||||||
|
})
|
||||||
|
self.assertTrue(Family.objects.filter(name="Family toto").exists())
|
||||||
|
self.assertRedirects(response, reverse("family:manage"), 302, 200)
|
||||||
|
|
||||||
|
def test_family_detail(self):
|
||||||
|
"""
|
||||||
|
Test display the detail of a family
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:family_detail", args=(self.family.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_family_update(self):
|
||||||
|
"""
|
||||||
|
Test update a family
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:family_update", args=(self.family.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("family:family_update", args=(self.family.pk,)), data=dict(
|
||||||
|
name="Toto family updated",
|
||||||
|
description="A larger description for the test family"
|
||||||
|
))
|
||||||
|
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
|
||||||
|
self.assertTrue(Family.objects.filter(name="Toto family updated").exists())
|
||||||
|
|
||||||
|
def test_family_update_picture(self):
|
||||||
|
"""
|
||||||
|
Test update the picture of a family
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:update_pic", args=(self.family.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
old_pic = self.family.display_image
|
||||||
|
|
||||||
|
with open("apps/family/static/family/img/default_picture.png", "rb") as f:
|
||||||
|
image = SimpleUploadedFile("image.png", f.read(), "image/png")
|
||||||
|
response = self.client.post(reverse("family:update_pic", args=(self.family.pk,)), dict(
|
||||||
|
image=image,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=200,
|
||||||
|
height=200,
|
||||||
|
))
|
||||||
|
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
|
||||||
|
|
||||||
|
self.family.refresh_from_db()
|
||||||
|
self.assertTrue(os.path.exists(self.family.display_image.path))
|
||||||
|
os.remove(self.family.display_image.path)
|
||||||
|
|
||||||
|
self.family.display_image = old_pic
|
||||||
|
self.family.save()
|
||||||
|
|
||||||
|
def test_family_add_member(self):
|
||||||
|
"""
|
||||||
|
Test add memberships to a family
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:family_add_member", args=(self.family.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
user = User.objects.create(username="totototo")
|
||||||
|
user.profile.registration_valid = True
|
||||||
|
user.profile.email_confirmed = True
|
||||||
|
user.profile.save()
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
response = self.client.post(reverse("family:family_add_member", args=(self.family.pk,)), data=dict(
|
||||||
|
user=user.pk,
|
||||||
|
))
|
||||||
|
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
|
||||||
|
|
||||||
|
self.assertTrue(FamilyMembership.objects.filter(user=user, family=self.family, year=timezone.now().year).exists())
|
||||||
|
|
||||||
|
def test_challenge_list(self):
|
||||||
|
"""
|
||||||
|
Test display challenge list
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse('family:challenge_list'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_challenge_create(self):
|
||||||
|
"""
|
||||||
|
Test create a challenge
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:challenge_create"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("family:challenge_create"), data={
|
||||||
|
"name": "Challenge for toto",
|
||||||
|
"description": "A test challenge",
|
||||||
|
"points": 50,
|
||||||
|
})
|
||||||
|
self.assertTrue(Challenge.objects.filter(name="Challenge for toto").exists())
|
||||||
|
self.assertRedirects(response, reverse("family:manage"), 302, 200)
|
||||||
|
|
||||||
|
def test_challenge_detail(self):
|
||||||
|
"""
|
||||||
|
Test display the detail of a challenge
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:challenge_detail", args=(self.challenge.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_challenge_update(self):
|
||||||
|
"""
|
||||||
|
Test update a challenge
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:challenge_update", args=(self.challenge.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("family:challenge_update", args=(self.challenge.pk,)), data=dict(
|
||||||
|
name="Challenge updated",
|
||||||
|
description="Another description",
|
||||||
|
points=10,
|
||||||
|
))
|
||||||
|
self.assertRedirects(response, self.challenge.get_absolute_url(), 302, 200)
|
||||||
|
self.assertTrue(Challenge.objects.filter(name="Challenge updated").exists())
|
||||||
|
|
||||||
|
def test_render_manage_page(self):
|
||||||
|
"""
|
||||||
|
Test render manage page
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:manage"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_validate_achievement(self):
|
||||||
|
"""
|
||||||
|
Test validate an achievement
|
||||||
|
"""
|
||||||
|
old_family_score = self.family.score
|
||||||
|
|
||||||
|
response = self.client.get(reverse("family:achievement_validate", args=(self.achievement.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("family:achievement_validate", args=(self.achievement.pk,)))
|
||||||
|
self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
|
||||||
|
|
||||||
|
self.achievement.refresh_from_db()
|
||||||
|
self.assertIs(self.achievement.valid, True)
|
||||||
|
|
||||||
|
self.family.refresh_from_db()
|
||||||
|
self.assertEqual(self.family.score, old_family_score + self.achievement.challenge.points)
|
||||||
|
|
||||||
|
def test_delete_achievement(self):
|
||||||
|
"""
|
||||||
|
Test delete an achievement
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("family:achievement_delete", args=(self.achievement.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.delete(reverse("family:achievement_delete", args=(self.achievement.pk,)))
|
||||||
|
self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
|
||||||
|
self.assertFalse(Achievement.objects.filter(pk=self.achievement.pk).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchAchievements(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
username='admintoto',
|
||||||
|
password='toto1234',
|
||||||
|
email='toto@example.com',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
sess = self.client.session
|
||||||
|
sess['permission_mask'] = 42
|
||||||
|
sess.save()
|
||||||
|
|
||||||
|
self.families = [
|
||||||
|
Family.objects.create(name=f'Famille {i}', description='') for i in range(2)
|
||||||
|
]
|
||||||
|
self.challenges = [
|
||||||
|
Challenge.objects.create(name=f'Challenge {i}', description='', points=50) for i in range(3)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.achievement = Achievement.objects.create(
|
||||||
|
family=self.families[0],
|
||||||
|
challenge=self.challenges[0],
|
||||||
|
valid=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.url = reverse("family:api:batch_achievements")
|
||||||
|
|
||||||
|
def test_batch_achievement_creation(self):
|
||||||
|
family_ids = [f.id for f in self.families]
|
||||||
|
challenge_ids = [c.id for c in self.challenges]
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data={
|
||||||
|
'families': family_ids,
|
||||||
|
'challenges': challenge_ids
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
for result in response.data['results']:
|
||||||
|
if result['family'] == self.families[0].name and result['challenge'] == self.challenges[0].name:
|
||||||
|
self.assertEqual(result['status'], 'existed')
|
||||||
|
else:
|
||||||
|
self.assertEqual(result['status'], 'created')
|
||||||
|
|
||||||
|
expected_count = len(family_ids) * len(challenge_ids)
|
||||||
|
self.assertEqual(Achievement.objects.count(), expected_count)
|
||||||
|
|
||||||
|
# Check that correct couples family/challenge exist
|
||||||
|
for f in self.families:
|
||||||
|
for c in self.challenges:
|
||||||
|
self.assertTrue(
|
||||||
|
Achievement.objects.filter(family=f, challenge=c).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFamilyAPI(TestAPI):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.family = Family.objects.create(
|
||||||
|
name='Test family',
|
||||||
|
description='',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.familymembership = FamilyMembership.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
family=self.family,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.challenge = Challenge.objects.create(
|
||||||
|
name='Test challenge',
|
||||||
|
description='',
|
||||||
|
points=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.achievement = Achievement.objects.create(
|
||||||
|
family=self.family,
|
||||||
|
challenge=self.challenge,
|
||||||
|
valid=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_family_api(self):
|
||||||
|
"""
|
||||||
|
Load Family API page and test all filters and permissions
|
||||||
|
"""
|
||||||
|
self.check_viewset(FamilyViewSet, '/api/family/family/')
|
||||||
|
|
||||||
|
def test_familymembership_api(self):
|
||||||
|
"""
|
||||||
|
Load FamilyMembership API page and test all filters and permissions
|
||||||
|
"""
|
||||||
|
self.check_viewset(FamilyMembershipViewSet, '/api/family/familymembership/')
|
||||||
|
|
||||||
|
def test_challenge_api(self):
|
||||||
|
"""
|
||||||
|
Load Challenge API page and test all filters and permissions
|
||||||
|
"""
|
||||||
|
self.check_viewset(ChallengeViewSet, '/api/family/challenge/')
|
||||||
|
|
||||||
|
def test_achievement_api(self):
|
||||||
|
"""
|
||||||
|
Load Achievement API page and test all filters and permissions
|
||||||
|
"""
|
||||||
|
self.check_viewset(AchievementViewSet, '/api/family/achievement/')
|
25
apps/family/urls.py
Normal file
25
apps/family/urls.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'family'
|
||||||
|
urlpatterns = [
|
||||||
|
path('list/', views.FamilyListView.as_view(), name="family_list"),
|
||||||
|
path('create/', views.FamilyCreateView.as_view(), name="family_create"),
|
||||||
|
path('<int:pk>/detail/', views.FamilyDetailView.as_view(), name="family_detail"),
|
||||||
|
path('<int:pk>/update/', views.FamilyUpdateView.as_view(), name="family_update"),
|
||||||
|
path('<int:pk>/update_pic/', views.FamilyPictureUpdateView.as_view(), name="update_pic"),
|
||||||
|
path('<int:family_pk>/add_member/', views.FamilyAddMemberView.as_view(), name="family_add_member"),
|
||||||
|
path('challenge/list/', views.ChallengeListView.as_view(), name="challenge_list"),
|
||||||
|
path('challenge/create/', views.ChallengeCreateView.as_view(), name="challenge_create"),
|
||||||
|
path('challenge/<int:pk>/detail/', views.ChallengeDetailView.as_view(), name="challenge_detail"),
|
||||||
|
path('challenge/<int:pk>/update/', views.ChallengeUpdateView.as_view(), name="challenge_update"),
|
||||||
|
path('manage/', views.FamilyManageView.as_view(), name="manage"),
|
||||||
|
path('achievement/list/', views.AchievementListView.as_view(), name="achievement_list"),
|
||||||
|
path('achievement/<int:pk>/validate/', views.AchievementValidateView.as_view(), name="achievement_validate"),
|
||||||
|
path('achievement/<int:pk>/delete/', views.AchievementDeleteView.as_view(), name="achievement_delete"),
|
||||||
|
path('api/family/', include(('family.api.urls', 'family_api'), namespace='api')),
|
||||||
|
]
|
469
apps/family/views.py
Normal file
469
apps/family/views.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.db import transaction
|
||||||
|
from django.views.generic import DetailView, UpdateView, ListView
|
||||||
|
from django.views.generic.edit import DeleteView, FormMixin
|
||||||
|
from django.views.generic.base import TemplateView
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_tables2 import SingleTableView, MultiTableMixin
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from member.forms import ImageForm
|
||||||
|
import phonenumbers
|
||||||
|
|
||||||
|
from .models import Family, Challenge, FamilyMembership, User, Achievement
|
||||||
|
from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable, FamilyAchievementTable
|
||||||
|
from .forms import ChallengeForm, FamilyMembershipForm, FamilyForm
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
Create family
|
||||||
|
"""
|
||||||
|
model = Family
|
||||||
|
extra_context = {"title": _('Create family')}
|
||||||
|
form_class = FamilyForm
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
return Family(
|
||||||
|
name="",
|
||||||
|
description="Sample family",
|
||||||
|
score=0,
|
||||||
|
rank=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse_lazy("family:manage")
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||||
|
"""
|
||||||
|
List existing Families
|
||||||
|
"""
|
||||||
|
model = Family
|
||||||
|
table_class = FamilyTable
|
||||||
|
extra_context = {"title": _('Families list')}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
fake_family = Family(name="", description="")
|
||||||
|
fake_challenge = Challenge(name="", description="", points=0)
|
||||||
|
can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
|
||||||
|
can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
|
||||||
|
|
||||||
|
if Family.objects.exists() and Challenge.objects.exists():
|
||||||
|
fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
|
||||||
|
can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
|
||||||
|
else:
|
||||||
|
can_add_achievement = False
|
||||||
|
|
||||||
|
context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Display details of a family
|
||||||
|
"""
|
||||||
|
model = Family
|
||||||
|
context_object_name = "family"
|
||||||
|
extra_context = {"title": _('Family detail')}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Add members list
|
||||||
|
"""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
family = self.object
|
||||||
|
|
||||||
|
# member list
|
||||||
|
family_member = FamilyMembership.objects.filter(
|
||||||
|
family=family,
|
||||||
|
year=date.today().year,
|
||||||
|
).filter(PermissionBackend.filter_queryset(self.request, FamilyMembership, "view"))\
|
||||||
|
.order_by("user__username")
|
||||||
|
family_member = family_member.distinct("user__username")\
|
||||||
|
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else family_member
|
||||||
|
|
||||||
|
membership_table = FamilyMembershipTable(data=family_member, prefix="membership-")
|
||||||
|
membership_table.paginate(per_page=5, page=self.request.GET.get('membership-page', 1))
|
||||||
|
context['member_list'] = membership_table
|
||||||
|
|
||||||
|
# Check if the user has the right to create a membership, to display the button.
|
||||||
|
empty_membership = FamilyMembership(
|
||||||
|
family=family,
|
||||||
|
user=User.objects.first(),
|
||||||
|
year=date.today().year,
|
||||||
|
)
|
||||||
|
context["can_add_members"] = PermissionBackend()\
|
||||||
|
.has_perm(self.request.user, "family.add_membership", empty_membership)
|
||||||
|
|
||||||
|
# Défis réalisé par la famille
|
||||||
|
achievements = Achievement.objects.filter(family=family)
|
||||||
|
achievements_table = FamilyAchievementTable(data=achievements, prefix="achievement-")
|
||||||
|
achievements_table.paginate(per_page=5, page=self.request.GET.get('achievement-page', 1))
|
||||||
|
context["achievement_list"] = achievements_table
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Update the information of a family.
|
||||||
|
"""
|
||||||
|
model = Family
|
||||||
|
context_object_name = "family"
|
||||||
|
form_class = FamilyForm
|
||||||
|
extra_context = {"title": _('Update family')}
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyPictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Update profile picture of the family
|
||||||
|
"""
|
||||||
|
model = Family
|
||||||
|
extra_context = {"title": _("Update family picture")}
|
||||||
|
template_name = 'family/picture_update.html'
|
||||||
|
form_class = ImageForm
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['form'] = self.form_class(self.request.POST, self.request.FILES)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
"""Redirect to family page after upload"""
|
||||||
|
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
form = self.get_form()
|
||||||
|
self.object = self.get_object()
|
||||||
|
return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""
|
||||||
|
Save the image
|
||||||
|
"""
|
||||||
|
image = form.cleaned_data['image']
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
image = "pic/default.png"
|
||||||
|
else:
|
||||||
|
# Rename as PNG or GIF
|
||||||
|
extension = image.name.split(".")[-1]
|
||||||
|
if extension == "gif":
|
||||||
|
image.name = "{}_pic.gif".format(self.object.pk)
|
||||||
|
else:
|
||||||
|
image.name = "{}_pic.png".format(self.object.pk)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
self.object.display_image = image
|
||||||
|
self.object.save()
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
Add a membership to a family
|
||||||
|
"""
|
||||||
|
model = FamilyMembership
|
||||||
|
form_class = FamilyMembershipForm
|
||||||
|
template_name = 'family/add_member.html'
|
||||||
|
extra_context = {"title": _("Add a new member to the family")}
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
if "family_pk" in self.kwargs:
|
||||||
|
family = Family.objects.get(pk=self.kwargs["family_pk"])
|
||||||
|
else:
|
||||||
|
family = FamilyMembership.objects.get(pk=self.kwargs["pk"]).family
|
||||||
|
return FamilyMembership(
|
||||||
|
user=self.request.user,
|
||||||
|
family=family,
|
||||||
|
year=date.today().year,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view"))\
|
||||||
|
.get(pk=self.kwargs['family_pk'])
|
||||||
|
|
||||||
|
context['family'] = family
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""
|
||||||
|
Create family membership, check that everythinf is good
|
||||||
|
"""
|
||||||
|
family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view")) \
|
||||||
|
.get(pk=self.kwargs["family_pk"])
|
||||||
|
|
||||||
|
form.instance.family = family
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.family.id})
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
Create challenge
|
||||||
|
"""
|
||||||
|
model = Challenge
|
||||||
|
extra_context = {"title": _('Create challenge')}
|
||||||
|
form_class = ChallengeForm
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
return Challenge(
|
||||||
|
name="",
|
||||||
|
description="Sample challenge",
|
||||||
|
points=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('family:manage')
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||||
|
"""
|
||||||
|
List all challenges
|
||||||
|
"""
|
||||||
|
model = Challenge
|
||||||
|
table_class = ChallengeTable
|
||||||
|
extra_context = {"title": _('Challenges list')}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
fake_family = Family(name="", description="")
|
||||||
|
fake_challenge = Challenge(name="", description="", points=0)
|
||||||
|
can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
|
||||||
|
can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
|
||||||
|
|
||||||
|
if Family.objects.exists() and Challenge.objects.exists():
|
||||||
|
fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
|
||||||
|
can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
|
||||||
|
else:
|
||||||
|
can_add_achievement = False
|
||||||
|
|
||||||
|
context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Display details of a challenge
|
||||||
|
"""
|
||||||
|
model = Challenge
|
||||||
|
context_object_name = "challenge"
|
||||||
|
extra_context = {"title": _('Details of:')}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
fields = ["name", "description", "points",]
|
||||||
|
|
||||||
|
fields = dict([(field, getattr(self.object, field)) for field in fields])
|
||||||
|
|
||||||
|
context["fields"] = [(
|
||||||
|
Challenge._meta.get_field(field).verbose_name.capitalize(),
|
||||||
|
value) for field, value in fields.items()]
|
||||||
|
context["obtained"] = self.object.obtained
|
||||||
|
context["update"] = PermissionBackend.check_perm(self.request, "family.change_challenge")
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Update the information of a challenge
|
||||||
|
"""
|
||||||
|
model = Challenge
|
||||||
|
context_object_name = "challenge"
|
||||||
|
extra_context = {"title": _('Update challenge')}
|
||||||
|
form_class = ChallengeForm
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse_lazy('family:challenge_detail', kwargs={'pk': self.object.pk})
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||||
|
"""
|
||||||
|
Manage families and challenges
|
||||||
|
"""
|
||||||
|
model = Achievement
|
||||||
|
template_name = 'family/manage.html'
|
||||||
|
table_class = AchievementTable
|
||||||
|
extra_context = {'title': _('Manage families and challenges')}
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
# Check that the user is authenticated
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return self.handle_no_permission()
|
||||||
|
|
||||||
|
perm = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
|
||||||
|
perm = perm or PermissionBackend.has_model_perm(self.request, Challenge(), "add")
|
||||||
|
perm = perm or PermissionBackend.has_model_perm(self.request, Family(), "add")
|
||||||
|
if not perm:
|
||||||
|
raise PermissionDenied(_("You are not able to manage families and challenges."))
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self, **kwargs):
|
||||||
|
# retrieves only Transaction that user has the right to see.
|
||||||
|
return Achievement.objects.filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Achievement, "view")
|
||||||
|
).order_by("-obtained_at").all()
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
context['all_challenges'] = Challenge.objects.filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Challenge, "view")
|
||||||
|
).order_by('name')
|
||||||
|
|
||||||
|
context["can_add_family"] = PermissionBackend.has_model_perm(self.request, Family(), "add")
|
||||||
|
context["can_add_challenge"] = PermissionBackend.has_model_perm(self.request, Challenge(), "add")
|
||||||
|
context["can_add_achievement"] = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
|
||||||
|
|
||||||
|
# Get the user's family if they have one
|
||||||
|
try:
|
||||||
|
user_family_membership = FamilyMembership.objects.get(user=self.request.user)
|
||||||
|
context["user_family"] = user_family_membership.family
|
||||||
|
except FamilyMembership.DoesNotExist:
|
||||||
|
context["user_family"] = None
|
||||||
|
|
||||||
|
phone_numbers = [
|
||||||
|
u.profile.phone_number for u in User.objects.filter(
|
||||||
|
memberships__roles__id=35,
|
||||||
|
memberships__date_end__gte=date.today(),
|
||||||
|
profile__phone_number__isnull=False
|
||||||
|
).distinct()
|
||||||
|
]
|
||||||
|
formatted_phone_numbers = [phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.INTERNATIONAL) for num in phone_numbers if num]
|
||||||
|
context["phone_numbers"] = formatted_phone_numbers
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_table(self, **kwargs):
|
||||||
|
table = super().get_table(**kwargs)
|
||||||
|
table.exclude = ('delete', 'validate',)
|
||||||
|
table.orderable = False
|
||||||
|
return table
|
||||||
|
|
||||||
|
def get_table_data(self, **kwargs):
|
||||||
|
qs = super().get_queryset(**kwargs)
|
||||||
|
|
||||||
|
qs = qs.filter(PermissionBackend.filter_queryset(self.request, Achievement, "view"))
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||||
|
"""
|
||||||
|
List all achievements
|
||||||
|
"""
|
||||||
|
model = Achievement
|
||||||
|
tables = [AchievementTable, AchievementTable, ]
|
||||||
|
extra_context = {'title': _('Achievement list')}
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return self.handle_no_permission()
|
||||||
|
|
||||||
|
if not PermissionBackend.has_model_perm(self.request, Achievement(), "change"):
|
||||||
|
raise PermissionDenied(_("You are not able to see the achievement validation interface."))
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_tables(self, **kwargs):
|
||||||
|
tables = super().get_tables(**kwargs)
|
||||||
|
|
||||||
|
tables[0].prefix = 'invalid-'
|
||||||
|
tables[1].prefix = 'valid-'
|
||||||
|
tables[1].exclude = ('validate', 'delete',)
|
||||||
|
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
table_valid = self.get_queryset().filter(valid=True)
|
||||||
|
table_invalid = self.get_queryset().filter(valid=False)
|
||||||
|
return [table_invalid, table_valid, ]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
tables = context['tables']
|
||||||
|
|
||||||
|
context['invalid'] = tables[0]
|
||||||
|
context['valid'] = tables[1]
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementValidateView(ProtectQuerysetMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""
|
||||||
|
Validate an achievement obtained by a family
|
||||||
|
"""
|
||||||
|
template_name = 'family/achievement_confirm_validate.html'
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return self.handle_no_permission()
|
||||||
|
|
||||||
|
fake_achievement = Achievement(
|
||||||
|
family=Family.objects.first(),
|
||||||
|
challenge=Challenge.objects.first(),
|
||||||
|
valid=False,
|
||||||
|
)
|
||||||
|
if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
|
||||||
|
raise PermissionDenied()
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
achievement = Achievement.objects.get(pk=pk)
|
||||||
|
|
||||||
|
achievement.valid = True
|
||||||
|
achievement.save()
|
||||||
|
|
||||||
|
return redirect(reverse_lazy('family:achievement_list'))
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
|
||||||
|
"""
|
||||||
|
Delete an Achievement
|
||||||
|
"""
|
||||||
|
model = Achievement
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return self.handle_no_permission()
|
||||||
|
|
||||||
|
fake_achievement = Achievement(
|
||||||
|
family=Family.objects.first(),
|
||||||
|
challenge=Challenge.objects.first(),
|
||||||
|
valid=False,
|
||||||
|
)
|
||||||
|
if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
|
||||||
|
raise PermissionDenied()
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('family:achievement_list')
|
@@ -145,7 +145,7 @@ class AddIngredientForms(forms.ModelForm):
|
|||||||
polymorphic_ctype__model="transformedfood",
|
polymorphic_ctype__model="transformedfood",
|
||||||
is_ready=False,
|
is_ready=False,
|
||||||
end_of_life='',
|
end_of_life='',
|
||||||
).filter(PermissionBackend.filter_queryset(get_current_request(), TransformedFood, "change")).exclude(pk=pk)
|
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")).exclude(pk=pk)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransformedFood
|
model = TransformedFood
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models import Food
|
from .models import Food
|
||||||
|
|
||||||
@@ -10,10 +11,25 @@ class FoodTable(tables.Table):
|
|||||||
"""
|
"""
|
||||||
List all foods.
|
List all foods.
|
||||||
"""
|
"""
|
||||||
|
qr_code_numbers = tables.Column(empty_values=(), verbose_name=_("QR Codes"), orderable=False)
|
||||||
|
|
||||||
|
date = tables.Column(empty_values=(), verbose_name=_("Arrival/creation date"), orderable=False)
|
||||||
|
|
||||||
|
def render_date(self, record):
|
||||||
|
if record.__class__.__name__ == "BasicFood":
|
||||||
|
return record.arrival_date.strftime("%d/%m/%Y %H:%M")
|
||||||
|
elif record.__class__.__name__ == "TransformedFood":
|
||||||
|
return record.creation_date.strftime("%d/%m/%Y %H:%M")
|
||||||
|
else:
|
||||||
|
return "--"
|
||||||
|
|
||||||
|
def render_qr_code_numbers(self, record):
|
||||||
|
return ", ".join(str(q.qr_code_number) for q in record.QR_code.all())
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Food
|
model = Food
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
fields = ('name', 'owner', 'allergens', 'expiry_date')
|
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date')
|
||||||
row_attrs = {
|
row_attrs = {
|
||||||
'class': 'table-row',
|
'class': 'table-row',
|
||||||
'data-href': lambda record: 'detail/' + str(record.pk),
|
'data-href': lambda record: 'detail/' + str(record.pk),
|
||||||
|
@@ -12,18 +12,21 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ul>
|
<ul>
|
||||||
|
{% if QR_code %}
|
||||||
|
<li> {{QR_code}} </li>
|
||||||
|
{% endif %}
|
||||||
{% for field, value in fields %}
|
{% for field, value in fields %}
|
||||||
<li> {{ field }} : {{ value }}</li>
|
<li> {{ field }} : {{ value }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if meals %}
|
{% if meals %}
|
||||||
<li> {% trans "Contained in" %} :
|
<li> {% trans "Contained in" %} :
|
||||||
{% for meal in meals %}
|
{% for meal in meals %}
|
||||||
<a href="{% url "food:transformedfood_view" pk=meal.pk %}">{{ meal.name }}</a>{% if not forloop.last %},{% endif %}
|
<a href="{% url "food:transformedfood_view" pk=meal.pk %}">{{ meal.name }}</a>{% if not forloop.last %},{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if foods %}
|
{% if foods %}
|
||||||
<li> {% trans "Contain" %} :
|
<li> {% trans "Contain" %} :
|
||||||
{% for food in foods %}
|
{% for food in foods %}
|
||||||
<a href="{% url "food:food_view" pk=food.pk %}">{{ food.name }}</a>{% if not forloop.last %},{% endif %}
|
<a href="{% url "food:food_view" pk=food.pk %}">{{ food.name }}</a>{% if not forloop.last %},{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -31,23 +34,23 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if update %}
|
{% if update %}
|
||||||
<a class="btn btn-sm btn-secondary" href="{% url "food:food_update" pk=food.pk %}">
|
<a class="btn btn-sm btn-secondary" href="{% url "food:food_update" pk=food.pk %}">
|
||||||
{% trans "Update" %}
|
{% trans "Update" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if add_ingredient %}
|
{% if add_ingredient %}
|
||||||
<a class="btn btn-sm btn-primary" href="{% url "food:add_ingredient" pk=food.pk %}">
|
<a class="btn btn-sm btn-primary" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||||
{% trans "Add to a meal" %}
|
{% trans "Add to a meal" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if manage_ingredients %}
|
{% if manage_ingredients %}
|
||||||
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
|
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
|
||||||
{% trans "Manage ingredients" %}
|
{% trans "Manage ingredients" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
|
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
|
||||||
{% trans "Return to the food list" %}
|
{% trans "Return to the food list" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -7,7 +7,58 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ block.super }}
|
<div class="card bg-light">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<style>
|
||||||
|
input[type=number]::-webkit-inner-spin-button,
|
||||||
|
input[type=number]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type=number] {
|
||||||
|
appearance: textfield;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="d-flex align-items-center" style="max-width: 300px;">
|
||||||
|
<form method="get" action="{% url 'food:redirect_view' %}" class="d-flex w-100">
|
||||||
|
<input type="number" name="slug" placeholder="QR-code" required class="form-control form-control-sm" style="max-width: 120px;">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">{% trans "View food" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check">
|
||||||
|
<label for="stock_only" class="form-check-label">
|
||||||
|
<input id="stock_only" name="stock_only" type="checkbox" class="checkboxinput form-check-input" checked>
|
||||||
|
{% trans "Filter with only food in stock" %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input id="searchbar" type="text" class="form-control"
|
||||||
|
placeholder="{% trans "Search by attribute such as name..." %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_inside_card %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<div id="dynamic-table">
|
||||||
|
{% if table.data %}
|
||||||
|
{% render_table table %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "There is no results." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<br>
|
<br>
|
||||||
<div class="card bg-light mb-3">
|
<div class="card bg-light mb-3">
|
||||||
<h3 class="card-header text-center">
|
<h3 class="card-header text-center">
|
||||||
@@ -68,4 +119,39 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
let old_pattern = null;
|
||||||
|
let searchbar_obj = $("#searchbar");
|
||||||
|
let stock_only_obj = $("#stock_only");
|
||||||
|
|
||||||
|
function reloadTable() {
|
||||||
|
let pattern = searchbar_obj.val();
|
||||||
|
|
||||||
|
$("#dynamic-table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
|
||||||
|
stock_only_obj.is(':checked') ? "" : "&stock=1") + " #dynamic-table");
|
||||||
|
}
|
||||||
|
|
||||||
|
searchbar_obj.keyup(reloadTable);
|
||||||
|
stock_only_obj.change(reloadTable);
|
||||||
|
|
||||||
|
$(document).on("click", ".table-row", function () {
|
||||||
|
window.document.location = $(this).data("href");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.getElementById('goButton').addEventListener('click', function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const slug = document.getElementById('slugInput').value;
|
||||||
|
if (slug && !isNaN(slug)) {
|
||||||
|
window.location.href = `/food/${slug}/`;
|
||||||
|
} else {
|
||||||
|
alert("Veuillez entrer un nombre valide.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
@@ -18,4 +18,5 @@ urlpatterns = [
|
|||||||
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
|
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
|
||||||
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
|
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
|
||||||
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
||||||
|
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'),
|
||||||
]
|
]
|
||||||
|
@@ -10,6 +10,7 @@ from django.db.models import Q
|
|||||||
from django.http import HttpResponseRedirect, Http404
|
from django.http import HttpResponseRedirect, Http404
|
||||||
from django.views.generic import DetailView, UpdateView, CreateView
|
from django.views.generic import DetailView, UpdateView, CreateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
|
from django.views.generic.base import RedirectView
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
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 _
|
||||||
@@ -64,16 +65,24 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
|
|||||||
suffix = '__iregex' if valid_regex else '__istartswith'
|
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||||
prefix = '^' if valid_regex else ''
|
prefix = '^' if valid_regex else ''
|
||||||
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
|
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
|
||||||
| Q(**{f'owner__name{suffix}': prefix + pattern}))
|
| Q(**{f'owner__name{suffix}': prefix + pattern})
|
||||||
|
| Q(**{f'owner__note__alias__name{suffix}': prefix + pattern}))
|
||||||
else:
|
else:
|
||||||
qs = qs.none()
|
qs = qs.none()
|
||||||
|
if "stock" not in self.request.GET or not self.request.GET["stock"] == '1':
|
||||||
|
qs = qs.filter(end_of_life='')
|
||||||
|
|
||||||
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||||
# table open
|
# table open
|
||||||
open_table = self.get_queryset().order_by('expiry_date').filter(
|
open_table = self.get_queryset().filter(
|
||||||
Q(polymorphic_ctype__model='transformedfood')
|
Q(polymorphic_ctype__model='transformedfood')
|
||||||
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
|
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
|
||||||
expiry_date__lt=timezone.now(), end_of_life='').filter(
|
expiry_date__lt=timezone.now(), end_of_life='').filter(
|
||||||
PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||||
|
open_table = open_table.union(self.get_queryset().filter(
|
||||||
|
Q(end_of_life='', order__iexact='open')
|
||||||
|
).filter(
|
||||||
|
PermissionBackend.filter_queryset(self.request, Food, 'view'))).order_by('expiry_date')
|
||||||
# table served
|
# table served
|
||||||
served_table = self.get_queryset().order_by('-pk').filter(
|
served_table = self.get_queryset().order_by('-pk').filter(
|
||||||
end_of_life='', is_ready=True).exclude(
|
end_of_life='', is_ready=True).exclude(
|
||||||
@@ -94,6 +103,7 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
|
|||||||
owner=club, end_of_life='').filter(
|
owner=club, end_of_life='').filter(
|
||||||
PermissionBackend.filter_queryset(self.request, Food, 'view')
|
PermissionBackend.filter_queryset(self.request, Food, 'view')
|
||||||
))
|
))
|
||||||
|
|
||||||
return [search_table, open_table, served_table] + club_table
|
return [search_table, open_table, served_table] + club_table
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@@ -217,7 +227,7 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
copy = self.request.GET.get('copy', None)
|
copy = self.request.GET.get('copy', None)
|
||||||
if copy is not None:
|
if copy is not None:
|
||||||
food = BasicFood.objects.get(pk=copy)
|
food = BasicFood.objects.get(pk=copy)
|
||||||
print(context['form'].fields)
|
|
||||||
for field in context['form'].fields:
|
for field in context['form'].fields:
|
||||||
if field == 'allergens':
|
if field == 'allergens':
|
||||||
context['form'].fields[field].initial = getattr(food, field).all()
|
context['form'].fields[field].initial = getattr(food, field).all()
|
||||||
@@ -454,6 +464,8 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
context["fields"] = [(
|
context["fields"] = [(
|
||||||
Food._meta.get_field(field).verbose_name.capitalize(),
|
Food._meta.get_field(field).verbose_name.capitalize(),
|
||||||
value) for field, value in fields.items()]
|
value) for field, value in fields.items()]
|
||||||
|
if self.object.QR_code.exists():
|
||||||
|
context["QR_code"] = self.object.QR_code.first()
|
||||||
context["meals"] = self.object.transformed_ingredient_inv.all()
|
context["meals"] = self.object.transformed_ingredient_inv.all()
|
||||||
context["update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
context["update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
||||||
context["add_ingredient"] = (self.object.end_of_life == '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood"))
|
context["add_ingredient"] = (self.object.end_of_life == '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood"))
|
||||||
@@ -507,3 +519,14 @@ class TransformedFoodDetailView(FoodDetailView):
|
|||||||
if Food.objects.filter(pk=kwargs['pk']).count() == 1:
|
if Food.objects.filter(pk=kwargs['pk']).count() == 1:
|
||||||
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood')
|
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood')
|
||||||
return super().get(*args, **kwargs)
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeRedirectView(RedirectView):
|
||||||
|
"""
|
||||||
|
Redirects to the QR code creation page from Food List
|
||||||
|
"""
|
||||||
|
def get_redirect_url(self, *args, **kwargs):
|
||||||
|
slug = self.request.GET.get('slug')
|
||||||
|
if slug:
|
||||||
|
return reverse_lazy('food:qrcode_create', kwargs={'slug': slug})
|
||||||
|
return reverse_lazy('food:list')
|
||||||
|
@@ -6,7 +6,7 @@ from django.conf import settings
|
|||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .signals import save_user_profile
|
from .signals import save_user_profile, update_wei_registration_fee_on_membership_creation, update_wei_registration_fee_on_club_change
|
||||||
|
|
||||||
|
|
||||||
class MemberConfig(AppConfig):
|
class MemberConfig(AppConfig):
|
||||||
@@ -17,7 +17,16 @@ class MemberConfig(AppConfig):
|
|||||||
"""
|
"""
|
||||||
Define app internal signals to interact with other apps
|
Define app internal signals to interact with other apps
|
||||||
"""
|
"""
|
||||||
|
from .models import Membership, Club
|
||||||
post_save.connect(
|
post_save.connect(
|
||||||
save_user_profile,
|
save_user_profile,
|
||||||
sender=settings.AUTH_USER_MODEL,
|
sender=settings.AUTH_USER_MODEL,
|
||||||
)
|
)
|
||||||
|
post_save.connect(
|
||||||
|
update_wei_registration_fee_on_membership_creation,
|
||||||
|
sender=Membership
|
||||||
|
)
|
||||||
|
post_save.connect(
|
||||||
|
update_wei_registration_fee_on_club_change,
|
||||||
|
sender=Club
|
||||||
|
)
|
||||||
|
@@ -10,6 +10,7 @@ from django.contrib.auth.forms import AuthenticationForm
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.forms import CheckboxSelectMultiple
|
from django.forms import CheckboxSelectMultiple
|
||||||
|
from phonenumber_field.formfields import PhoneNumberField
|
||||||
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
|
||||||
@@ -45,6 +46,11 @@ class ProfileForm(forms.ModelForm):
|
|||||||
A form for the extras field provided by the :model:`member.Profile` model.
|
A form for the extras field provided by the :model:`member.Profile` model.
|
||||||
"""
|
"""
|
||||||
# Remove widget=forms.HiddenInput() if you want to use report frequency.
|
# Remove widget=forms.HiddenInput() if you want to use report frequency.
|
||||||
|
phone_number = PhoneNumberField(
|
||||||
|
widget=forms.TextInput(attrs={"type": "tel", "class": "form-control"}),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
|
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
|
||||||
|
|
||||||
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
|
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
|
||||||
@@ -72,7 +78,12 @@ class ProfileForm(forms.ModelForm):
|
|||||||
if not self.instance.section or (("department" in self.changed_data
|
if not self.instance.section or (("department" in self.changed_data
|
||||||
or "promotion" in self.changed_data) and "section" not in self.changed_data):
|
or "promotion" in self.changed_data) and "section" not in self.changed_data):
|
||||||
self.instance.section = self.instance.section_generated
|
self.instance.section = self.instance.section_generated
|
||||||
return super().save(commit)
|
instance = super().save(commit=False)
|
||||||
|
if instance.phone_number:
|
||||||
|
instance.phone_number = instance.phone_number.as_e164
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Profile
|
model = Profile
|
||||||
|
18
apps/member/migrations/0015_alter_profile_promotion.py
Normal file
18
apps/member/migrations/0015_alter_profile_promotion.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-02 13:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('member', '0014_create_bda'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='profile',
|
||||||
|
name='promotion',
|
||||||
|
field=models.PositiveSmallIntegerField(default=2025, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
|
||||||
|
),
|
||||||
|
]
|
@@ -438,8 +438,6 @@ class Membership(models.Model):
|
|||||||
)
|
)
|
||||||
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
|
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
|
||||||
new_membership._force_renew_parent = True
|
new_membership._force_renew_parent = True
|
||||||
if hasattr(self, '_soge') and self._soge:
|
|
||||||
new_membership._soge = True
|
|
||||||
if hasattr(self, '_force_save') and self._force_save:
|
if hasattr(self, '_force_save') and self._force_save:
|
||||||
new_membership._force_save = True
|
new_membership._force_save = True
|
||||||
new_membership.save()
|
new_membership.save()
|
||||||
@@ -458,8 +456,6 @@ class Membership(models.Model):
|
|||||||
# Renew the previous membership of the parent club
|
# Renew the previous membership of the parent club
|
||||||
parent_membership = parent_membership.first()
|
parent_membership = parent_membership.first()
|
||||||
parent_membership._force_renew_parent = True
|
parent_membership._force_renew_parent = True
|
||||||
if hasattr(self, '_soge'):
|
|
||||||
parent_membership._soge = True
|
|
||||||
if hasattr(self, '_force_save'):
|
if hasattr(self, '_force_save'):
|
||||||
parent_membership._force_save = True
|
parent_membership._force_save = True
|
||||||
parent_membership.renew()
|
parent_membership.renew()
|
||||||
@@ -471,8 +467,6 @@ class Membership(models.Model):
|
|||||||
date_start=self.date_start,
|
date_start=self.date_start,
|
||||||
)
|
)
|
||||||
parent_membership._force_renew_parent = True
|
parent_membership._force_renew_parent = True
|
||||||
if hasattr(self, '_soge'):
|
|
||||||
parent_membership._soge = True
|
|
||||||
if hasattr(self, '_force_save'):
|
if hasattr(self, '_force_save'):
|
||||||
parent_membership._force_save = True
|
parent_membership._force_save = True
|
||||||
parent_membership.save()
|
parent_membership.save()
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def save_user_profile(instance, created, raw, **_kwargs):
|
def save_user_profile(instance, created, raw, **_kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -13,3 +15,27 @@ def save_user_profile(instance, created, raw, **_kwargs):
|
|||||||
instance.profile.email_confirmed = True
|
instance.profile.email_confirmed = True
|
||||||
instance.profile.registration_valid = True
|
instance.profile.registration_valid = True
|
||||||
instance.profile.save()
|
instance.profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
def update_wei_registration_fee_on_membership_creation(sender, instance, created, **kwargs):
|
||||||
|
if not hasattr(instance, "_no_signal") and 'wei' in settings.INSTALLED_APPS and created:
|
||||||
|
from wei.models import WEIRegistration
|
||||||
|
if instance.club.id == 1 or instance.club.id == 2:
|
||||||
|
registrations = WEIRegistration.objects.filter(
|
||||||
|
user=instance.user,
|
||||||
|
wei__year=instance.date_start.year,
|
||||||
|
)
|
||||||
|
for r in registrations:
|
||||||
|
r._force_save = True
|
||||||
|
r.save()
|
||||||
|
|
||||||
|
|
||||||
|
def update_wei_registration_fee_on_club_change(sender, instance, **kwargs):
|
||||||
|
if not hasattr(instance, "_no_signal") and 'wei' in settings.INSTALLED_APPS and (instance.id == 1 or instance.id == 2):
|
||||||
|
from wei.models import WEIRegistration
|
||||||
|
registrations = WEIRegistration.objects.filter(
|
||||||
|
wei__year=instance.membership_start.year,
|
||||||
|
)
|
||||||
|
for r in registrations:
|
||||||
|
r._force_save = True
|
||||||
|
r.save()
|
||||||
|
@@ -92,6 +92,20 @@ class MembershipTable(tables.Table):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
user_email = tables.Column(
|
||||||
|
verbose_name="Email",
|
||||||
|
accessor="user.email",
|
||||||
|
orderable=False,
|
||||||
|
visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_full_name = tables.Column(
|
||||||
|
verbose_name=_("Full name"),
|
||||||
|
accessor="user.get_full_name",
|
||||||
|
orderable=False,
|
||||||
|
visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
def render_user(self, value):
|
def render_user(self, value):
|
||||||
# If the user has the right, link the displayed user with the page of its detail.
|
# If the user has the right, link the displayed user with the page of its detail.
|
||||||
s = value.username
|
s = value.username
|
||||||
@@ -149,6 +163,16 @@ class MembershipTable(tables.Table):
|
|||||||
+ "'>" + s + "</a>")
|
+ "'>" + s + "</a>")
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
def value_user(self, record):
|
||||||
|
return record.user.username if record.user else ""
|
||||||
|
|
||||||
|
def value_club(self, record):
|
||||||
|
return record.club.name if record.club else ""
|
||||||
|
|
||||||
|
def value_roles(self, record):
|
||||||
|
roles = record.roles.all()
|
||||||
|
return ", ".join(str(role) for role in roles)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
attrs = {
|
attrs = {
|
||||||
'class': 'table table-condensed table-striped',
|
'class': 'table table-condensed table-striped',
|
||||||
|
@@ -36,7 +36,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% trans "There is no membership found with this pattern." %}
|
{% trans "There is no membership found with this pattern." %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=csv'">
|
||||||
|
{% trans "Export to CSV" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -7,6 +7,19 @@
|
|||||||
<dt class="col-xl-6">{% trans 'username'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'username'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ user_object.username }}</dd>
|
<dd class="col-xl-6">{{ user_object.username }}</dd>
|
||||||
|
|
||||||
|
{% if family_app_installed %}
|
||||||
|
<dt class="col-xl-6">{% trans 'family'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">
|
||||||
|
{% if families %}
|
||||||
|
{% for fam in families %}
|
||||||
|
<a href="{% url 'family:family_detail' fam.pk %}">{{ fam.name }}</a>{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Aucune</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if user_object.pk == user.pk %}
|
{% if user_object.pk == user.pk %}
|
||||||
<dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">
|
<dd class="col-xl-6">
|
||||||
|
@@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post" id="profile-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form | crispy }}
|
{{ form | crispy }}
|
||||||
{{ profile_form | crispy }}
|
{{ profile_form | crispy }}
|
||||||
@@ -20,4 +20,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<!-- intl-tel-input CSS/JS -->
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const input = document.querySelector("input[name='phone_number']");
|
||||||
|
const form = document.querySelector("#profile-form");
|
||||||
|
|
||||||
|
if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iti = window.intlTelInput(input, {
|
||||||
|
initialCountry: "auto",
|
||||||
|
nationalMode: false,
|
||||||
|
autoPlaceholder: "off",
|
||||||
|
geoIpLookup: callback => {
|
||||||
|
fetch("https://ipapi.co/json")
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => callback(data.country_code))
|
||||||
|
.catch(() => callback("fr"));
|
||||||
|
},
|
||||||
|
loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", function(e){
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = iti.getNumber(intlTelInput.utils.numberFormat.E164);
|
||||||
|
if (number) {
|
||||||
|
input.value = number;
|
||||||
|
form.submit();
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
@@ -44,7 +44,7 @@ class TemplateLoggedInTests(TestCase):
|
|||||||
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
|
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
|
||||||
|
|
||||||
def test_logout(self):
|
def test_logout(self):
|
||||||
response = self.client.get(reverse("logout"))
|
response = self.client.post(reverse("logout"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_admin_index(self):
|
def test_admin_index(self):
|
||||||
|
@@ -17,6 +17,7 @@ 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 MultiTableMixin, SingleTableMixin, SingleTableView
|
from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
|
||||||
|
from django_tables2.export.views import ExportMixin
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from api.viewsets import is_regex
|
from api.viewsets import is_regex
|
||||||
from note.models import Alias, NoteClub, NoteUser, Trust
|
from note.models import Alias, NoteClub, NoteUser, Trust
|
||||||
@@ -26,6 +27,7 @@ from note_kfet.middlewares import _set_current_request
|
|||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
from permission.models import Role
|
from permission.models import Role
|
||||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||||
|
from family.models import Family
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \
|
from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \
|
||||||
@@ -206,6 +208,10 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
|||||||
modified_note.is_active = True
|
modified_note.is_active = True
|
||||||
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
|
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
|
||||||
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
|
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
|
||||||
|
if 'family' in settings.INSTALLED_APPS:
|
||||||
|
context["family_app_installed"] = True
|
||||||
|
families = Family.objects.filter(memberships__user=user).distinct()
|
||||||
|
context["families"] = families
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@@ -945,11 +951,12 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
|||||||
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id})
|
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id})
|
||||||
|
|
||||||
|
|
||||||
class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||||
model = Membership
|
model = Membership
|
||||||
table_class = MembershipTable
|
table_class = MembershipTable
|
||||||
template_name = "member/club_members.html"
|
template_name = "member/club_members.html"
|
||||||
extra_context = {"title": _("Members of the club")}
|
extra_context = {"title": _("Members of the club")}
|
||||||
|
export_formats = ["csv"]
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
qs = super().get_queryset().filter(club_id=self.kwargs["pk"])
|
qs = super().get_queryset().filter(club_id=self.kwargs["pk"])
|
||||||
@@ -981,6 +988,14 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
|
|||||||
|
|
||||||
return qs.distinct()
|
return qs.distinct()
|
||||||
|
|
||||||
|
def get_export_filename(self, export_format):
|
||||||
|
return "members.csv"
|
||||||
|
|
||||||
|
def get_export_content_type(self, export_format):
|
||||||
|
if export_format == "csv":
|
||||||
|
return "text/csv"
|
||||||
|
return super().get_export_content_type(export_format)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
club = Club.objects.filter(
|
club = Club.objects.filter(
|
||||||
|
@@ -13,7 +13,7 @@ def register_note_urls(router, path):
|
|||||||
router.register(path + '/note', NotePolymorphicViewSet)
|
router.register(path + '/note', NotePolymorphicViewSet)
|
||||||
router.register(path + '/alias', AliasViewSet)
|
router.register(path + '/alias', AliasViewSet)
|
||||||
router.register(path + '/trust', TrustViewSet)
|
router.register(path + '/trust', TrustViewSet)
|
||||||
router.register(path + '/consumer', ConsumerViewSet)
|
router.register(path + '/consumer', ConsumerViewSet, basename='alias2')
|
||||||
|
|
||||||
router.register(path + '/transaction/category', TemplateCategoryViewSet)
|
router.register(path + '/transaction/category', TemplateCategoryViewSet)
|
||||||
router.register(path + '/transaction/transaction', TransactionViewSet)
|
router.register(path + '/transaction/transaction', TransactionViewSet)
|
||||||
|
@@ -66,6 +66,8 @@ $(document).ready(function () {
|
|||||||
arr.push(last)
|
arr.push(last)
|
||||||
|
|
||||||
last.quantity = 1
|
last.quantity = 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (last.note.club) {
|
if (last.note.club) {
|
||||||
$('#last_name').val(last.note.name)
|
$('#last_name').val(last.note.name)
|
||||||
@@ -111,7 +113,8 @@ $(document).ready(function () {
|
|||||||
dest.removeClass('d-none')
|
dest.removeClass('d-none')
|
||||||
$('#dest_note_list').removeClass('d-none')
|
$('#dest_note_list').removeClass('d-none')
|
||||||
$('#debit_type').addClass('d-none')
|
$('#debit_type').addClass('d-none')
|
||||||
|
$('#reason').val('')
|
||||||
|
|
||||||
$('#source_note_label').text(select_emitters_label)
|
$('#source_note_label').text(select_emitters_label)
|
||||||
$('#dest_note_label').text(select_receveirs_label)
|
$('#dest_note_label').text(select_receveirs_label)
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ $(document).ready(function () {
|
|||||||
dest.val('')
|
dest.val('')
|
||||||
dest.tooltip('hide')
|
dest.tooltip('hide')
|
||||||
$('#debit_type').addClass('d-none')
|
$('#debit_type').addClass('d-none')
|
||||||
|
$('#reason').val('Rechargement note')
|
||||||
|
|
||||||
$('#source_note_label').text(transfer_type_label)
|
$('#source_note_label').text(transfer_type_label)
|
||||||
$('#dest_note_label').text(select_receveir_label)
|
$('#dest_note_label').text(select_receveir_label)
|
||||||
@@ -162,6 +166,7 @@ $(document).ready(function () {
|
|||||||
dest.addClass('d-none')
|
dest.addClass('d-none')
|
||||||
dest.tooltip('hide')
|
dest.tooltip('hide')
|
||||||
$('#debit_type').removeClass('d-none')
|
$('#debit_type').removeClass('d-none')
|
||||||
|
$('#reason').val('')
|
||||||
|
|
||||||
$('#source_note_label').text(select_emitter_label)
|
$('#source_note_label').text(select_emitter_label)
|
||||||
$('#dest_note_label').text(transfer_type_label)
|
$('#dest_note_label').text(transfer_type_label)
|
||||||
|
@@ -1391,12 +1391,12 @@
|
|||||||
"wei",
|
"wei",
|
||||||
"weiregistration"
|
"weiregistration"
|
||||||
],
|
],
|
||||||
"query": "{\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}",
|
"query": "[\"AND\", {\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}, {\"deposit_type\": \"note\"}]",
|
||||||
"type": "change",
|
"type": "change",
|
||||||
"mask": 2,
|
"mask": 2,
|
||||||
"field": "caution_check",
|
"field": "deposit_given",
|
||||||
"permanent": false,
|
"permanent": false,
|
||||||
"description": "Dire si un chèque de caution est donné pour une inscription WEI"
|
"description": "Autoriser une transaction de caution WEI"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4347,7 +4347,375 @@
|
|||||||
"mask": 3,
|
"mask": 3,
|
||||||
"field": "",
|
"field": "",
|
||||||
"permanent": false,
|
"permanent": false,
|
||||||
"description": "Ajouter un membre au BDE ou à la Kfet"
|
"description": "Faire adhérer BDE ou Kfet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 293,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"wei",
|
||||||
|
"weimembership"
|
||||||
|
],
|
||||||
|
"query": "[\"AND\", {\"bus\": [\"membership\", \"weimembership\", \"bus\"]}, {\"club\": [\"club\"], \"club__weiclub__membership_end__gte\": [\"today\"]}]",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "team",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Modifier l'équipe d'une adhésion WEI à son bus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 294,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"wei",
|
||||||
|
"weiregistration"
|
||||||
|
],
|
||||||
|
"query": "[\"AND\", {\"wei__year\": [\"today\", \"year\"], \"wei__membership_start__lte\": [\"today\"], \"wei__membership_end__gte\": [\"today\"]}, {\"deposit_type\": \"check\"}]",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "deposit_given",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Dire si un chèque de caution a été donné"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 295,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"wei",
|
||||||
|
"weiregistration"
|
||||||
|
],
|
||||||
|
"query": "{\"wei__year\": [\"today\", \"year\"]}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir toutes les inscriptions au WEI courant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 296,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"wei",
|
||||||
|
"weimembership"
|
||||||
|
],
|
||||||
|
"query": "{\"club__weiclub__year\": [\"today\", \"year\"]}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir toutes les adhésions au WEI courant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 297,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"wei",
|
||||||
|
"weiregistration"
|
||||||
|
],
|
||||||
|
"query": "[\"AND\", {\"user\": [\"user\"], \"wei__membership_start__lte\": [\"today\"], \"wei__membership_end__gte\": [\"today\"]}, [\"OR\", {\"wei\": [\"club\"]}, {\"wei__year\": [\"today\", \"year\"], \"membership\": null}]]",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 1,
|
||||||
|
"field": "deposit_type",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 298,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"wei",
|
||||||
|
"bus"
|
||||||
|
],
|
||||||
|
"query": "{\"pk\": [\"membership\", \"weimembership\", \"bus\", \"pk\"], \"wei__date_end__gte\": [\"today\"]}",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "information_json",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Modifier les informations du bus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 311,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"family"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 1,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir toutes les familles"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 312,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"family"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "add",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Créer une famille"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 313,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"family"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Modifier n'importe quelle famille"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 314,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"family"
|
||||||
|
],
|
||||||
|
"query": "{\"pk\": [\"user\", \"family_memberships\", \"family\", \"pk\"]}",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Modifier ma famille"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 315,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"familymembership"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir les membres de n'importe quelle famille"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 316,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"familymembership"
|
||||||
|
],
|
||||||
|
"query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 1,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir les membres de ma famille"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 317,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"familymembership"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "add",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Ajouter un membre à n'importe quelle famille"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 318,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"familymembership"
|
||||||
|
],
|
||||||
|
"query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
|
||||||
|
"type": "add",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Ajouter un membre à ma famille"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 319,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"challenge"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 1,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir tous les défis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 320,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"challenge"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "add",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Créer un défi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 321,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"challenge"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Modifier un défi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 322,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"challenge"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "delete",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "{}",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Supprimer un défi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 323,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"achievement"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 1,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir tous les succès"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 324,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"achievement"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "add",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Créer un succès"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 325,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"achievement"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "change",
|
||||||
|
"mask": 1,
|
||||||
|
"field": "valid",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Valider un succès"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 326,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"family",
|
||||||
|
"achievement"
|
||||||
|
],
|
||||||
|
"query": "{}",
|
||||||
|
"type": "delete",
|
||||||
|
"mask": 1,
|
||||||
|
"field": "",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Supprimer un succès"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.permission",
|
||||||
|
"pk": 330,
|
||||||
|
"fields": {
|
||||||
|
"model": [
|
||||||
|
"auth",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"query": "{\"memberships__club\": [\"club\"]}",
|
||||||
|
"type": "view",
|
||||||
|
"mask": 2,
|
||||||
|
"field": "email",
|
||||||
|
"permanent": false,
|
||||||
|
"description": "Voir l'adresse mail des membres de son club"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4402,9 +4770,13 @@
|
|||||||
206,
|
206,
|
||||||
248,
|
248,
|
||||||
249,
|
249,
|
||||||
255,
|
255,
|
||||||
256,
|
256,
|
||||||
257
|
257,
|
||||||
|
311,
|
||||||
|
316,
|
||||||
|
319,
|
||||||
|
323
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4444,7 +4816,8 @@
|
|||||||
159,
|
159,
|
||||||
160,
|
160,
|
||||||
212,
|
212,
|
||||||
222
|
222,
|
||||||
|
297
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4492,7 +4865,11 @@
|
|||||||
221,
|
221,
|
||||||
247,
|
247,
|
||||||
258,
|
258,
|
||||||
259
|
259,
|
||||||
|
260,
|
||||||
|
263,
|
||||||
|
265,
|
||||||
|
330
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4504,7 +4881,6 @@
|
|||||||
"name": "Pr\u00e9sident\u22c5e de club",
|
"name": "Pr\u00e9sident\u22c5e de club",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
62,
|
62,
|
||||||
135,
|
|
||||||
142
|
142
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4631,7 +5007,10 @@
|
|||||||
176,
|
176,
|
||||||
177,
|
177,
|
||||||
178,
|
178,
|
||||||
183
|
183,
|
||||||
|
294,
|
||||||
|
295,
|
||||||
|
296
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4764,7 +5143,6 @@
|
|||||||
"name": "Chef\u22c5fe de bus",
|
"name": "Chef\u22c5fe de bus",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
22,
|
22,
|
||||||
84,
|
|
||||||
115,
|
115,
|
||||||
117,
|
117,
|
||||||
118,
|
118,
|
||||||
@@ -4778,7 +5156,9 @@
|
|||||||
287,
|
287,
|
||||||
289,
|
289,
|
||||||
290,
|
290,
|
||||||
291
|
291,
|
||||||
|
293,
|
||||||
|
298
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4790,7 +5170,6 @@
|
|||||||
"name": "Chef\u22c5fe d'\u00e9quipe",
|
"name": "Chef\u22c5fe d'\u00e9quipe",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
22,
|
22,
|
||||||
84,
|
|
||||||
116,
|
116,
|
||||||
123,
|
123,
|
||||||
124,
|
124,
|
||||||
@@ -4805,20 +5184,7 @@
|
|||||||
"for_club": null,
|
"for_club": null,
|
||||||
"name": "\u00c9lectron libre",
|
"name": "\u00c9lectron libre",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
22,
|
22
|
||||||
84
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "permission.role",
|
|
||||||
"pk": 16,
|
|
||||||
"fields": {
|
|
||||||
"for_club": null,
|
|
||||||
"name": "\u00c9lectron libre (avec perm)",
|
|
||||||
"permissions": [
|
|
||||||
22,
|
|
||||||
84
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4852,6 +5218,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
37,
|
37,
|
||||||
41,
|
41,
|
||||||
|
42,
|
||||||
53,
|
53,
|
||||||
54,
|
54,
|
||||||
55,
|
55,
|
||||||
@@ -4903,7 +5270,9 @@
|
|||||||
168,
|
168,
|
||||||
176,
|
176,
|
||||||
177,
|
177,
|
||||||
197
|
197,
|
||||||
|
311,
|
||||||
|
319
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4938,7 +5307,7 @@
|
|||||||
216
|
216
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 23,
|
"pk": 23,
|
||||||
@@ -4951,7 +5320,7 @@
|
|||||||
32
|
32
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 24,
|
"pk": 24,
|
||||||
@@ -4960,7 +5329,7 @@
|
|||||||
"name": "Staffeur⋅euse (S&L,Respo Tech,...)",
|
"name": "Staffeur⋅euse (S&L,Respo Tech,...)",
|
||||||
"permissions": []
|
"permissions": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 25,
|
"pk": 25,
|
||||||
@@ -4969,7 +5338,6 @@
|
|||||||
"name": "Référent⋅e Bus",
|
"name": "Référent⋅e Bus",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
22,
|
22,
|
||||||
84,
|
|
||||||
115,
|
115,
|
||||||
117,
|
117,
|
||||||
118,
|
118,
|
||||||
@@ -4983,10 +5351,12 @@
|
|||||||
287,
|
287,
|
||||||
289,
|
289,
|
||||||
290,
|
290,
|
||||||
291
|
291,
|
||||||
|
293,
|
||||||
|
298
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 28,
|
"pk": 28,
|
||||||
@@ -5016,7 +5386,7 @@
|
|||||||
269
|
269
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 30,
|
"pk": 30,
|
||||||
@@ -5024,15 +5394,15 @@
|
|||||||
"for_club": 10,
|
"for_club": 10,
|
||||||
"name": "Respo sorties",
|
"name": "Respo sorties",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
49,
|
49,
|
||||||
62,
|
62,
|
||||||
141,
|
141,
|
||||||
241,
|
241,
|
||||||
242,
|
242,
|
||||||
243
|
243
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 31,
|
"pk": 31,
|
||||||
@@ -5044,7 +5414,7 @@
|
|||||||
244
|
244
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 32,
|
"pk": 32,
|
||||||
@@ -5056,7 +5426,7 @@
|
|||||||
245
|
245
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "permission.role",
|
"model": "permission.role",
|
||||||
"pk": 33,
|
"pk": 33,
|
||||||
@@ -5064,15 +5434,48 @@
|
|||||||
"for_club": 10,
|
"for_club": 10,
|
||||||
"name": "Respo Jam",
|
"name": "Respo Jam",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
247,
|
247,
|
||||||
250,
|
250,
|
||||||
251,
|
251,
|
||||||
252,
|
252,
|
||||||
253,
|
253,
|
||||||
254
|
254
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.role",
|
||||||
|
"pk": 34,
|
||||||
|
"fields": {
|
||||||
|
"for_club": 1,
|
||||||
|
"name": "Chef·fe de famille",
|
||||||
|
"permissions": [
|
||||||
|
314,
|
||||||
|
318,
|
||||||
|
324
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "permission.role",
|
||||||
|
"pk": 35,
|
||||||
|
"fields": {
|
||||||
|
"for_club": 1,
|
||||||
|
"name": "Respo familles",
|
||||||
|
"permissions": [
|
||||||
|
312,
|
||||||
|
313,
|
||||||
|
315,
|
||||||
|
317,
|
||||||
|
320,
|
||||||
|
321,
|
||||||
|
322,
|
||||||
|
324,
|
||||||
|
325,
|
||||||
|
326
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"model": "wei.weirole",
|
"model": "wei.weirole",
|
||||||
"pk": 12,
|
"pk": 12,
|
||||||
@@ -5093,11 +5496,6 @@
|
|||||||
"pk": 15,
|
"pk": 15,
|
||||||
"fields": {}
|
"fields": {}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"model": "wei.weirole",
|
|
||||||
"pk": 16,
|
|
||||||
"fields": {}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"model": "wei.weirole",
|
"model": "wei.weirole",
|
||||||
"pk": 17,
|
"pk": 17,
|
||||||
|
@@ -18,7 +18,18 @@ class PermissionScopes(BaseScopes):
|
|||||||
and can be useful to make queries through the API with limited privileges.
|
and can be useful to make queries through the API with limited privileges.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_all_scopes(self):
|
def get_all_scopes(self, **kwargs):
|
||||||
|
scopes = {}
|
||||||
|
if 'scopes' in kwargs:
|
||||||
|
for scope in kwargs['scopes']:
|
||||||
|
if scope == 'openid':
|
||||||
|
scopes['openid'] = "OpenID Connect"
|
||||||
|
else:
|
||||||
|
p = Permission.objects.get(id=scope.split('_')[0])
|
||||||
|
club = Club.objects.get(id=scope.split('_')[1])
|
||||||
|
scopes[scope] = f"{p.description} (club {club.name})"
|
||||||
|
return scopes
|
||||||
|
|
||||||
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
for p in Permission.objects.all() for club in Club.objects.all()}
|
||||||
scopes['openid'] = "OpenID Connect"
|
scopes['openid'] = "OpenID Connect"
|
||||||
|
@@ -13,6 +13,7 @@ EXCLUDED = [
|
|||||||
'cas_server.serviceticket',
|
'cas_server.serviceticket',
|
||||||
'cas_server.user',
|
'cas_server.user',
|
||||||
'cas_server.userattributes',
|
'cas_server.userattributes',
|
||||||
|
'constance.constance',
|
||||||
'contenttypes.contenttype',
|
'contenttypes.contenttype',
|
||||||
'logs.changelog',
|
'logs.changelog',
|
||||||
'migrations.migration',
|
'migrations.migration',
|
||||||
|
@@ -164,14 +164,24 @@ class ScopesView(LoginRequiredMixin, TemplateView):
|
|||||||
from oauth2_provider.models import Application
|
from oauth2_provider.models import Application
|
||||||
from .scopes import PermissionScopes
|
from .scopes import PermissionScopes
|
||||||
|
|
||||||
scopes = PermissionScopes()
|
oidc = False
|
||||||
context["scopes"] = {}
|
context["scopes"] = {}
|
||||||
all_scopes = scopes.get_all_scopes()
|
|
||||||
for app in Application.objects.filter(user=self.request.user).all():
|
for app in Application.objects.filter(user=self.request.user).all():
|
||||||
available_scopes = scopes.get_available_scopes(app)
|
available_scopes = PermissionScopes().get_available_scopes(app)
|
||||||
context["scopes"][app] = OrderedDict()
|
context["scopes"][app] = OrderedDict()
|
||||||
items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes]
|
all_scopes = PermissionScopes().get_all_scopes(scopes=available_scopes)
|
||||||
# items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
|
scopes = {}
|
||||||
|
for scope in available_scopes:
|
||||||
|
scopes[scope] = all_scopes[scope]
|
||||||
|
# remove OIDC scope for sort
|
||||||
|
if 'openid' in scopes:
|
||||||
|
del scopes['openid']
|
||||||
|
oidc = True
|
||||||
|
items = [(k, v) for (k, v) in scopes.items()]
|
||||||
|
items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
|
||||||
|
# add oidc if necessary
|
||||||
|
if oidc:
|
||||||
|
items.append(('openid', PermissionScopes().get_all_scopes(scopes=['openid'])['openid']))
|
||||||
for k, v in items:
|
for k, v in items:
|
||||||
context["scopes"][app][k] = v
|
context["scopes"][app][k] = v
|
||||||
|
|
||||||
|
18
apps/treasury/migrations/0011_sogecredit_valid.py
Normal file
18
apps/treasury/migrations/0011_sogecredit_valid.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-28 20:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('treasury', '0010_alter_invoice_bde'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sogecredit',
|
||||||
|
name='valid',
|
||||||
|
field=models.BooleanField(blank=True, default=False, verbose_name='Valid'),
|
||||||
|
),
|
||||||
|
]
|
@@ -308,6 +308,12 @@ class SogeCredit(models.Model):
|
|||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
valid = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Valid"),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Credit from the Société générale")
|
verbose_name = _("Credit from the Société générale")
|
||||||
verbose_name_plural = _("Credits from the Société générale")
|
verbose_name_plural = _("Credits from the Société générale")
|
||||||
@@ -338,7 +344,7 @@ class SogeCredit(models.Model):
|
|||||||
credit_transaction.save()
|
credit_transaction.save()
|
||||||
credit_transaction.refresh_from_db()
|
credit_transaction.refresh_from_db()
|
||||||
self.credit_transaction = credit_transaction
|
self.credit_transaction = credit_transaction
|
||||||
elif not self.valid:
|
elif not self.valid_legacy:
|
||||||
self.credit_transaction.amount = self.amount
|
self.credit_transaction.amount = self.amount
|
||||||
self.credit_transaction._force_save = True
|
self.credit_transaction._force_save = True
|
||||||
self.credit_transaction.save()
|
self.credit_transaction.save()
|
||||||
@@ -346,20 +352,18 @@ class SogeCredit(models.Model):
|
|||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def valid(self):
|
def valid_legacy(self):
|
||||||
return self.credit_transaction and self.credit_transaction.valid
|
return self.credit_transaction and self.credit_transaction.valid
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def amount(self):
|
def amount(self):
|
||||||
if self.valid:
|
if self.valid_legacy:
|
||||||
return self.credit_transaction.total
|
return self.credit_transaction.total
|
||||||
amount = sum(transaction.total for transaction in self.transactions.all())
|
amount = 0
|
||||||
if 'wei' in settings.INSTALLED_APPS:
|
transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
|
||||||
from wei.models import WEIMembership
|
amount += sum(max(transaction.total - transaction.membership.club.weiclub.fee_soge_credit, 0) for transaction in transactions_wei)
|
||||||
if not WEIMembership.objects\
|
transactions_not_wei = self.transactions.filter(membership__club__weiclub__isnull=True)
|
||||||
.filter(club__weiclub__year=self.credit_transaction.created_at.year, user=self.user).exists():
|
amount += sum(transaction.total for transaction in transactions_not_wei)
|
||||||
# 80 € for people that don't go to WEI
|
|
||||||
amount += 8000
|
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
def update_transactions(self):
|
def update_transactions(self):
|
||||||
@@ -367,7 +371,7 @@ class SogeCredit(models.Model):
|
|||||||
The Sogé credit may be created after the user already paid its memberships.
|
The Sogé credit may be created after the user already paid its memberships.
|
||||||
We query transactions and update the credit, if it is unvalid.
|
We query transactions and update the credit, if it is unvalid.
|
||||||
"""
|
"""
|
||||||
if self.valid or not self.pk:
|
if self.valid_legacy or not self.pk:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Soge do not pay BDE and kfet memberships since 2022
|
# Soge do not pay BDE and kfet memberships since 2022
|
||||||
@@ -407,7 +411,7 @@ class SogeCredit(models.Model):
|
|||||||
Invalidating a Société générale delete the transaction of the bank if it was already created.
|
Invalidating a Société générale delete the transaction of the bank if it was already created.
|
||||||
Treasurers must know what they do, With Great Power Comes Great Responsibility...
|
Treasurers must know what they do, With Great Power Comes Great Responsibility...
|
||||||
"""
|
"""
|
||||||
if self.valid:
|
if self.valid_legacy:
|
||||||
self.credit_transaction.valid = False
|
self.credit_transaction.valid = False
|
||||||
self.credit_transaction.save()
|
self.credit_transaction.save()
|
||||||
for tr in self.transactions.all():
|
for tr in self.transactions.all():
|
||||||
@@ -416,7 +420,7 @@ class SogeCredit(models.Model):
|
|||||||
tr.save()
|
tr.save()
|
||||||
|
|
||||||
def validate(self, force=False):
|
def validate(self, force=False):
|
||||||
if self.valid and not force:
|
if self.valid_legacy and not force:
|
||||||
# The credit is already done
|
# The credit is already done
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -441,7 +445,7 @@ class SogeCredit(models.Model):
|
|||||||
With Great Power Comes Great Responsibility...
|
With Great Power Comes Great Responsibility...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
total_fee = sum(transaction.total for transaction in self.transactions.all() if not transaction.valid)
|
total_fee = self.amount
|
||||||
if self.user.note.balance < total_fee:
|
if self.user.note.balance < total_fee:
|
||||||
raise ValidationError(_("This user doesn't have enough money to pay the memberships with its note. "
|
raise ValidationError(_("This user doesn't have enough money to pay the memberships with its note. "
|
||||||
"Please ask her/him to credit the note before invalidating this credit."))
|
"Please ask her/him to credit the note before invalidating this credit."))
|
||||||
|
@@ -168,7 +168,7 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
|
class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
|
||||||
"""
|
"""
|
||||||
Delete a non-validated WEI registration
|
Delete a non-locked Invoice
|
||||||
"""
|
"""
|
||||||
model = Invoice
|
model = Invoice
|
||||||
extra_context = {"title": _("Delete invoice")}
|
extra_context = {"title": _("Delete invoice")}
|
||||||
|
@@ -77,7 +77,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet):
|
|||||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
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', 'deposit_given', 'birth_date', 'gender',
|
||||||
'clothing_cut', 'clothing_size', 'first_year', 'emergency_contact_name',
|
'clothing_cut', 'clothing_size', 'first_year', 'emergency_contact_name',
|
||||||
'emergency_contact_phone', ]
|
'emergency_contact_phone', ]
|
||||||
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email',
|
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email',
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from .registration import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, WEIMembership1AForm, \
|
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, \
|
||||||
WEIMembershipForm, BusForm, BusTeamForm
|
WEIMembershipForm, BusForm, BusTeamForm
|
||||||
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
|
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'WEIForm', 'WEIRegistrationForm', 'WEIRegistration1AForm', 'WEIRegistration2AForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||||
]
|
]
|
||||||
|
@@ -5,7 +5,7 @@ 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, RadioSelect
|
||||||
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, Autocomplete, ColorWidget
|
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
|
||||||
@@ -24,7 +24,8 @@ class WEIForm(forms.ModelForm):
|
|||||||
"membership_end": DatePickerInput(),
|
"membership_end": DatePickerInput(),
|
||||||
"date_start": DatePickerInput(),
|
"date_start": DatePickerInput(),
|
||||||
"date_end": DatePickerInput(),
|
"date_end": DatePickerInput(),
|
||||||
"caution_amount": AmountInput(),
|
"deposit_amount": AmountInput(),
|
||||||
|
"fee_soge_credit": AmountInput(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ class WEIRegistrationForm(forms.ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'user', 'soge_credit', 'birth_date', 'gender', 'clothing_size',
|
'user', 'soge_credit', 'birth_date', 'gender', 'clothing_size',
|
||||||
'health_issues', 'emergency_contact_name', 'emergency_contact_phone',
|
'health_issues', 'emergency_contact_name', 'emergency_contact_phone',
|
||||||
'first_year', 'information_json', 'caution_check'
|
'first_year', 'information_json', 'deposit_given', 'deposit_type'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"user": Autocomplete(
|
"user": Autocomplete(
|
||||||
@@ -58,30 +59,17 @@ class WEIRegistrationForm(forms.ModelForm):
|
|||||||
'minDate': '1900-01-01',
|
'minDate': '1900-01-01',
|
||||||
'maxDate': '2100-01-01'
|
'maxDate': '2100-01-01'
|
||||||
}),
|
}),
|
||||||
"caution_check": forms.BooleanField(
|
"deposit_given": forms.CheckboxInput(
|
||||||
required=False,
|
attrs={'class': 'form-check-input'},
|
||||||
),
|
),
|
||||||
|
"deposit_type": forms.RadioSelect(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class WEIRegistration2AForm(WEIRegistrationForm):
|
|
||||||
class Meta(WEIRegistrationForm.Meta):
|
|
||||||
fields = WEIRegistrationForm.Meta.fields + ['caution_type']
|
|
||||||
widgets = WEIRegistrationForm.Meta.widgets.copy()
|
|
||||||
widgets.update({
|
|
||||||
"caution_type": forms.RadioSelect(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class WEIRegistration1AForm(WEIRegistrationForm):
|
|
||||||
class Meta(WEIRegistrationForm.Meta):
|
|
||||||
fields = WEIRegistrationForm.Meta.fields
|
|
||||||
|
|
||||||
|
|
||||||
class WEIChooseBusForm(forms.Form):
|
class WEIChooseBusForm(forms.Form):
|
||||||
bus = forms.ModelMultipleChoiceField(
|
bus = forms.ModelMultipleChoiceField(
|
||||||
queryset=Bus.objects,
|
queryset=Bus.objects,
|
||||||
label=_("bus"),
|
label=_("Bus"),
|
||||||
help_text=_("This choice is not definitive. The WEI organizers are free to attribute for you a bus and a team,"
|
help_text=_("This choice is not definitive. The WEI organizers are free to attribute for you a bus and a team,"
|
||||||
+ " in particular if you are a free eletron."),
|
+ " in particular if you are a free eletron."),
|
||||||
widget=CheckboxSelectMultiple(),
|
widget=CheckboxSelectMultiple(),
|
||||||
@@ -99,7 +87,7 @@ class WEIChooseBusForm(forms.Form):
|
|||||||
queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")),
|
queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")),
|
||||||
label=_("WEI Roles"),
|
label=_("WEI Roles"),
|
||||||
help_text=_("Select the roles that you are interested in."),
|
help_text=_("Select the roles that you are interested in."),
|
||||||
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
|
initial=WEIRole.objects.filter(Q(name="Adhérent⋅e WEI") | Q(name="\u00c9lectron libre")).all(),
|
||||||
widget=CheckboxSelectMultiple(),
|
widget=CheckboxSelectMultiple(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +128,19 @@ class WEIMembershipForm(forms.ModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, wei=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if 'bus' in self.fields:
|
||||||
|
if wei is not None:
|
||||||
|
self.fields['bus'].queryset = Bus.objects.filter(wei=wei)
|
||||||
|
else:
|
||||||
|
self.fields['bus'].queryset = Bus.objects.none()
|
||||||
|
if 'team' in self.fields:
|
||||||
|
if wei is not None:
|
||||||
|
self.fields['team'].queryset = BusTeam.objects.filter(bus__wei=wei)
|
||||||
|
else:
|
||||||
|
self.fields['team'].queryset = BusTeam.objects.none()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
if 'team' in cleaned_data and cleaned_data["team"] is not None \
|
if 'team' in cleaned_data and cleaned_data["team"] is not None \
|
||||||
@@ -151,21 +152,8 @@ class WEIMembershipForm(forms.ModelForm):
|
|||||||
model = WEIMembership
|
model = WEIMembership
|
||||||
fields = ('roles', 'bus', 'team',)
|
fields = ('roles', 'bus', 'team',)
|
||||||
widgets = {
|
widgets = {
|
||||||
"bus": Autocomplete(
|
"bus": RadioSelect(),
|
||||||
Bus,
|
"team": RadioSelect(),
|
||||||
attrs={
|
|
||||||
'api_url': '/api/wei/bus/',
|
|
||||||
'placeholder': 'Bus ...',
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"team": Autocomplete(
|
|
||||||
BusTeam,
|
|
||||||
attrs={
|
|
||||||
'api_url': '/api/wei/team/',
|
|
||||||
'placeholder': 'Équipe ...',
|
|
||||||
},
|
|
||||||
resetable=True,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +161,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
|
deposit_given = None
|
||||||
roles = None
|
roles = None
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
@@ -213,4 +201,3 @@ class BusTeamForm(forms.ModelForm):
|
|||||||
),
|
),
|
||||||
"color": ColorWidget(),
|
"color": ColorWidget(),
|
||||||
}
|
}
|
||||||
# "color": ColorWidget(),
|
|
||||||
|
@@ -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 .wei2024 import WEISurvey2024
|
from .wei2025 import WEISurvey2025
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||||
]
|
]
|
||||||
|
|
||||||
CurrentSurvey = WEISurvey2024
|
CurrentSurvey = WEISurvey2025
|
||||||
|
@@ -121,6 +121,13 @@ class WEISurveyAlgorithm:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_bus_information_form(cls):
|
||||||
|
"""
|
||||||
|
The class of the form to update the bus information.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class WEISurvey:
|
class WEISurvey:
|
||||||
"""
|
"""
|
||||||
|
622
apps/wei/forms/surveys/wei2025.py
Normal file
622
apps/wei/forms/surveys/wei2025.py
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from random import Random
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
|
||||||
|
from ...models import WEIMembership, Bus
|
||||||
|
|
||||||
|
WORDS = {
|
||||||
|
'list': [
|
||||||
|
'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nerd et geek', 'Jeux de rôles et danse rock',
|
||||||
|
'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires',
|
||||||
|
'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif',
|
||||||
|
'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare',
|
||||||
|
],
|
||||||
|
'questions': {
|
||||||
|
"alcool": [
|
||||||
|
"""Sur une échelle allant de 0 (= 0 alcool ou très peu) à 5 (= la fontaine de jouvence alcoolique),
|
||||||
|
quel niveau de consommation d’alcool souhaiterais-tu ?""",
|
||||||
|
{
|
||||||
|
42: 4,
|
||||||
|
47: 1,
|
||||||
|
48: 3,
|
||||||
|
45: 3.5,
|
||||||
|
44: 4,
|
||||||
|
46: 5,
|
||||||
|
43: 3,
|
||||||
|
49: 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"voie_post_bac": [
|
||||||
|
"""Si la DA du bus de ton choix correspondait à une voie post-bac, laquelle serait-elle ?""",
|
||||||
|
{
|
||||||
|
42: "Double licence cuisine/arts du cirque option burger",
|
||||||
|
47: "BTS Exploration de donjon",
|
||||||
|
48: "Ecole des stars en herbe",
|
||||||
|
45: "Déscolarisation précoce",
|
||||||
|
44: "Rattrapage pour excès de kiff",
|
||||||
|
46: "Double cursus STAPS / Licence d’histoire",
|
||||||
|
43: "Recherche active d’un sugar daddy/d’un sugar mommy",
|
||||||
|
49: "Licence de musicologie"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"boite": [
|
||||||
|
"""Tu es seul·e sur une île déserte et devant toi il y a une sombre boîte de taille raisonnable.
|
||||||
|
Qu’y a-t-il à l’intérieur ?""",
|
||||||
|
{
|
||||||
|
42: "Un burgouzz de valouzz",
|
||||||
|
47: "Un ocarina (pour me téléporter hors de ce bourbier)",
|
||||||
|
48: "Des paillettes, un micro de karaoké et une enceinte bluetooth",
|
||||||
|
45: "Un kebab",
|
||||||
|
44: "Une 86 et un caisson pour taper du pied",
|
||||||
|
46: "Une épée, un ballon et une tireuse",
|
||||||
|
43: "Des lunettes de soleil",
|
||||||
|
49: "Mon instrument de musique"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tardif": [
|
||||||
|
"""Il est 00h, tu as passé la journée à la plage avec tes copains et iels te proposent de prolonger parce
|
||||||
|
qu’après tout, il n’y a plus personne sur la plage à cette heure-ci. Tu n’habites pas loin mais t’enchaînes
|
||||||
|
demain avec une journée similaire avec un autre groupe d’amis parce que t’es trop #busy. Que fais-tu ?""",
|
||||||
|
{
|
||||||
|
42: "On veut se déchaîner toute la nuit !!",
|
||||||
|
47: "Je prends une glace et chill un moment avant d’aller dormir",
|
||||||
|
48: "J’enfile mes boogie shoes pour enflammer le dancefloor avec elleux et lancer un concours de slay, le perdant finit la bouteille de rhum",
|
||||||
|
45: "La fête continuuuuue",
|
||||||
|
44: "Soirée sangria plage → boîte → lever de soleil sur la plage",
|
||||||
|
46: "Minuit ? C’est l’heure du genepi. On commence les alcools forts !!",
|
||||||
|
43: "T’enchaînes direct (faut pas les priver de ta présence)",
|
||||||
|
49: "On continue en mode chill (soirée potins)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cohesion": [
|
||||||
|
"""C’est la rentrée de Seconde et tu découvres ta classe, tes camarades et ta prof principale!!!
|
||||||
|
qui vous propose une activité de cohésion. Laquelle est-elle ?""",
|
||||||
|
{
|
||||||
|
42: "Un relais cubi en ventriglisse",
|
||||||
|
47: "Un jeu de rôle",
|
||||||
|
48: "Organiser la soirée de l’année dans le lycée. Le thème : SLAY (Spotlight, Love, Amaze/All-night, Yeah), paillettes, disco",
|
||||||
|
45: "La prof de français propose un slam parce qu'elle pense que c'est du rap littéraire qui fera plaisir aux élèves",
|
||||||
|
44: "P’tit escape game + apéro",
|
||||||
|
46: "Joute avec des boucliers Gilbert",
|
||||||
|
43: "Tournage d’un clip de confessions nocturnes de Diam’s",
|
||||||
|
49: "Je sais pas j’ai raté mon BAFA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artiste": [
|
||||||
|
"""C’est l’été et la saison des festivals a commencé. Tu regardes la programmation du festival
|
||||||
|
pas loin de chez toi et tu découvres avec joie la présence d’un·e artiste. De qui s’agit-il ?""",
|
||||||
|
{
|
||||||
|
42: "Moto-Moto (il chantera son fameux tube “je les aime grosses, je les aime bombées”)",
|
||||||
|
47: "Hatsune Miku",
|
||||||
|
48: "Rihanna",
|
||||||
|
45: "Vald",
|
||||||
|
44: "Qui connaît vraiment les noms des artistes de tech ?",
|
||||||
|
46: "Perceval",
|
||||||
|
43: "Fatal bazooka",
|
||||||
|
49: "Måneskin"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annonce_noel": [
|
||||||
|
"""C’est Noël et tu revois toute ta famille, oncles, tantes, cousin·e·s, grands-parents, la totale.
|
||||||
|
D’un coup, tu te lèves, tapotes de manière pompeuse sur ton verre avec un de tes couverts.
|
||||||
|
Qu’annonces-tu ?""",
|
||||||
|
{
|
||||||
|
42: """« Chère famille. Je sais bien que nous avions dit : pas de politique à table.
|
||||||
|
Je ne peux toutefois me retenir de vous annoncer une grande nouvelle…
|
||||||
|
j’ai décidé de quitter la ville pour consacrer ma vie au culte du Roi Julian.
|
||||||
|
A moi la jungle luxuriante, là où le soleil chaud caresse les palmiers,
|
||||||
|
où les lémuriens dansent avec frénésie et où chaque repas est une ode au burger sauvage.
|
||||||
|
Longue vie à Sa Majesté le Roi Julian ! »""",
|
||||||
|
47: "« J’ai perdu »",
|
||||||
|
48: "« Mes chers parents je pars, j’arrête l’ENS pour devenir DJ slay à Ibiza »",
|
||||||
|
45: "J’interromps le repas pour rapper les 6min de bande organisée",
|
||||||
|
44: "« Digestif ? Pétanque ? Les deux ? »",
|
||||||
|
46: "« Montjoie St Denis à bas la Macronie »",
|
||||||
|
43: "« Je suis enceinte » (c’est faux j’ai juste besoin d’attention)",
|
||||||
|
49: """Discours de remerciement :
|
||||||
|
je lance un powerpoint de 65 slides et sors une feuille A4 blanche (je fais semblant de lire mon discours dessus)"""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vacances": [
|
||||||
|
"""Les vacances sont là et t’aimerais bien partir quelque part, mais où ?""",
|
||||||
|
{
|
||||||
|
42: "A Madagascar, à bord d’un bus conduit par des pingouins",
|
||||||
|
47: "Dans ma chambre",
|
||||||
|
48: "Rio de Janeiro",
|
||||||
|
45: "N'importe où tant qu'on peut sortir tous les soirs",
|
||||||
|
44: "Tu suis les plans du club ski ou de piratens",
|
||||||
|
46: "Carcassonne",
|
||||||
|
43: "Coachella",
|
||||||
|
49: "Dans les montagnes de la république populaire d’Auvergne-Rhônes-Alpes pour profiter de la fraîcheur, de la nature et de mes ami·e·s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"loisir": [
|
||||||
|
"""T’as fini ta journée de cours et tu t’apprêtes à profiter d’une activité/hobby/loisir de ton choix.
|
||||||
|
Laquelle est-ce ?""",
|
||||||
|
{
|
||||||
|
42: "Cueillir des noix de coco",
|
||||||
|
47: "Essayer de travailler puis chill avec des potes autour d’un jeu en buvant du thé",
|
||||||
|
48: "Repet du nouveau spectacle de mon club, before (potins) puis sortie avec les potes jusqu’au bout de la night",
|
||||||
|
45: "Zoner avec les copaings jusqu’à pas d’heure",
|
||||||
|
44: "Go Kfet pour se faire traquenard jusqu’à 3h du mat",
|
||||||
|
46: "Déterminer ce qui est le plus solide entre mon crâne et une ecocup",
|
||||||
|
43: "Revoir pour la 6e fois gossip girl au fond de ton lit",
|
||||||
|
49: "Jouer de mon instrument préféré avec les copains/copines pour préparer le prochain concert #solidays"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plan": [
|
||||||
|
"""Tu reçois un message sur la conversation de groupe que tu partages avec tes potes :
|
||||||
|
vous êtes chaud·e·s pour vous retrouver. Quel plan t’attire le plus ?""",
|
||||||
|
{
|
||||||
|
42: """Après-midi piscine, puis before arrosé de mojito,
|
||||||
|
avant d’aller s’éclater en pot avec toute la savane et de finir sur un after spécial pina colada""",
|
||||||
|
47: """(matin) : Ptit jeu de rôle
|
||||||
|
(repas) : le traditionnel poké-tacos
|
||||||
|
(juste après le repas) : combat avec des épées en mousse avec les copains!
|
||||||
|
(16h00) : pause thé
|
||||||
|
(fin d’après midi) : initiation à la danse rock
|
||||||
|
(soirée) : découverte d’un jeu de société avec des règles obscures
|
||||||
|
""",
|
||||||
|
48: "Soirée champagne and chic : spectacle et dîner au moulin rouge puis soirée sur les champs",
|
||||||
|
45: "Se regrouper pour une soirée, même si il n’est encore que 10h",
|
||||||
|
44: "P’tit poké qui termine en koin koin avec after poker",
|
||||||
|
46: "Une dégustation de bière, un rugby et toute autre activité joviale",
|
||||||
|
43: "Un brunch de pour papoter puis friperies",
|
||||||
|
49: "Soirée raclette !"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'stats': [
|
||||||
|
{
|
||||||
|
"question": """Le WEI est structuré par bus, et au sein de chaque bus, par équipes.
|
||||||
|
Pour toi, être dans une équipe où tout le monde reste sobre (primo-entrants comme encadrants) c'est :""",
|
||||||
|
"answers": [
|
||||||
|
(1, "Inenvisageable"),
|
||||||
|
(2, "À contre cœur"),
|
||||||
|
(3, "Pourquoi pas"),
|
||||||
|
(4, "Souhaitable"),
|
||||||
|
(5, "Nécessaire"),
|
||||||
|
],
|
||||||
|
"help_text": "(De toute façon aucun alcool n'est consommé pendant les trajets du bus, ni aller, ni retour.)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Faire partie d'un bus qui n'apporte pas de boisson alcoolisée pour ses membres, pour toi c'est :",
|
||||||
|
"answers": [
|
||||||
|
(1, "Inenvisageable"),
|
||||||
|
(2, "À contre cœur"),
|
||||||
|
(3, "Pourquoi pas"),
|
||||||
|
(4, "Souhaitable"),
|
||||||
|
(5, "Nécessaire"),
|
||||||
|
],
|
||||||
|
"help_text": """(Tout les bus apportent de l'alcool cette année, cette question sert à l'organisation pour l'année prochaine.
|
||||||
|
De plus il y aura de toute façon de l'alcool commun au WEI et aucun alcool n'est consommé pendant les trajets en bus.)""",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
IMAGES = {
|
||||||
|
"vacances": {
|
||||||
|
49: "/static/wei/img/logo_auvergne_rhone_alpes.jpg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NB_WORDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalImageRadioSelect(forms.RadioSelect):
|
||||||
|
def __init__(self, images=None, *args, **kwargs):
|
||||||
|
self.images = images or {}
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||||
|
option = super().create_option(name, value, label, selected, index, subindex=subindex, attrs=attrs)
|
||||||
|
img_url = self.images.get(value)
|
||||||
|
if img_url:
|
||||||
|
option['label'] = mark_safe(f'{label} <img src="{img_url}" style="height:32px;vertical-align:middle;">')
|
||||||
|
else:
|
||||||
|
option['label'] = label
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurveyForm2025(forms.Form):
|
||||||
|
"""
|
||||||
|
Survey form for the year 2025.
|
||||||
|
Members choose 20 words, 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 = WEISurveyInformation2025(registration)
|
||||||
|
if not information.seed:
|
||||||
|
information.seed = int(1000 * time.time())
|
||||||
|
information.save(registration)
|
||||||
|
registration._force_save = True
|
||||||
|
registration.save()
|
||||||
|
|
||||||
|
rng = Random((information.step + 1) * information.seed)
|
||||||
|
|
||||||
|
if information.step == 0:
|
||||||
|
self.fields["words"] = forms.MultipleChoiceField(
|
||||||
|
label=_(f"Select {NB_WORDS} words that describe the WEI experience you want to have."),
|
||||||
|
choices=[(w, w) for w in WORDS['list']],
|
||||||
|
widget=forms.CheckboxSelectMultiple(),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
if self.is_valid():
|
||||||
|
return
|
||||||
|
|
||||||
|
all_preferred_words = WORDS['list']
|
||||||
|
rng.shuffle(all_preferred_words)
|
||||||
|
self.fields["words"].choices = [(w, w) for w in all_preferred_words]
|
||||||
|
elif information.step <= len(WORDS['questions']):
|
||||||
|
questions = list(WORDS['questions'].items())
|
||||||
|
idx = information.step - 1
|
||||||
|
if idx < len(questions):
|
||||||
|
q, (desc, answers) = questions[idx]
|
||||||
|
if q == 'alcool':
|
||||||
|
choices = [(i / 2, str(i / 2)) for i in range(11)]
|
||||||
|
else:
|
||||||
|
choices = [(k, v) for k, v in answers.items()]
|
||||||
|
rng.shuffle(choices)
|
||||||
|
self.fields[q] = forms.ChoiceField(
|
||||||
|
label=desc,
|
||||||
|
choices=choices,
|
||||||
|
widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
elif information.step == len(WORDS['questions']) + 1:
|
||||||
|
for i, v in enumerate(WORDS['stats']):
|
||||||
|
self.fields[f'stat_{i}'] = forms.ChoiceField(
|
||||||
|
label=v['question'],
|
||||||
|
choices=v['answers'],
|
||||||
|
widget=forms.RadioSelect(),
|
||||||
|
required=False,
|
||||||
|
help_text=_(v.get('help_text', ''))
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_words(self):
|
||||||
|
data = self.cleaned_data['words']
|
||||||
|
if len(data) != NB_WORDS:
|
||||||
|
raise forms.ValidationError(_(f"Please choose exactly {NB_WORDS} words"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class WEIBusInformation2025(WEIBusInformation):
|
||||||
|
"""
|
||||||
|
For each word, the bus has a score
|
||||||
|
"""
|
||||||
|
scores: dict
|
||||||
|
|
||||||
|
def __init__(self, bus):
|
||||||
|
self.scores = {}
|
||||||
|
super().__init__(bus)
|
||||||
|
|
||||||
|
|
||||||
|
class BusInformationForm2025(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Bus
|
||||||
|
fields = ['information_json']
|
||||||
|
widgets = {
|
||||||
|
'information_json': forms.HiddenInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, words=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
initial_scores = {}
|
||||||
|
if self.instance and self.instance.information_json:
|
||||||
|
try:
|
||||||
|
info = json.loads(self.instance.information_json)
|
||||||
|
initial_scores = info.get("scores", {})
|
||||||
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||||
|
initial_scores = {}
|
||||||
|
if words is None:
|
||||||
|
words = WORDS['list']
|
||||||
|
self.words = words
|
||||||
|
|
||||||
|
choices = [(i, str(i)) for i in range(6)] # [(0, '0'), (1, '1'), ..., (5, '5')]
|
||||||
|
for word in words:
|
||||||
|
self.fields[word] = forms.TypedChoiceField(
|
||||||
|
label=word,
|
||||||
|
choices=choices,
|
||||||
|
coerce=int,
|
||||||
|
initial=initial_scores.get(word, 0) if word in initial_scores else None,
|
||||||
|
required=True,
|
||||||
|
widget=forms.RadioSelect,
|
||||||
|
help_text=_("Rate between 0 and 5."),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
scores = {}
|
||||||
|
for word in self.words:
|
||||||
|
value = cleaned_data.get(word)
|
||||||
|
if value is not None:
|
||||||
|
scores[word] = value
|
||||||
|
# On encode en JSON
|
||||||
|
cleaned_data['information_json'] = json.dumps({"scores": scores})
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurveyInformation2025(WEISurveyInformation):
|
||||||
|
"""
|
||||||
|
We store the id of the selected bus. We store only the name, but is not used in the selection:
|
||||||
|
that's only for humans that try to read data.
|
||||||
|
"""
|
||||||
|
# Random seed that is stored at the first time to ensure that words are generated only once
|
||||||
|
seed = 0
|
||||||
|
step = 0
|
||||||
|
|
||||||
|
def __init__(self, registration):
|
||||||
|
for i in range(1, NB_WORDS + 1):
|
||||||
|
setattr(self, "word" + str(i), None)
|
||||||
|
for q in WORDS['questions']:
|
||||||
|
setattr(self, q, None)
|
||||||
|
super().__init__(registration)
|
||||||
|
|
||||||
|
def reset(self, registration):
|
||||||
|
"""
|
||||||
|
Réinitialise complètement le questionnaire : step, seed, mots choisis et réponses aux questions.
|
||||||
|
"""
|
||||||
|
self.step = 0
|
||||||
|
self.seed = 0
|
||||||
|
for i in range(1, NB_WORDS + 1):
|
||||||
|
setattr(self, f"word{i}", None)
|
||||||
|
for q in WORDS['questions']:
|
||||||
|
setattr(self, q, None)
|
||||||
|
self.save(registration)
|
||||||
|
registration._force_save = True
|
||||||
|
registration.save()
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurvey2025(WEISurvey):
|
||||||
|
"""
|
||||||
|
Survey for the year 2025.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_year(cls):
|
||||||
|
return 2025
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_survey_information_class(cls):
|
||||||
|
return WEISurveyInformation2025
|
||||||
|
|
||||||
|
def get_form_class(self):
|
||||||
|
return WEISurveyForm2025
|
||||||
|
|
||||||
|
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):
|
||||||
|
if self.information.step == 0:
|
||||||
|
words = form.cleaned_data['words']
|
||||||
|
for i, word in enumerate(words, 1):
|
||||||
|
setattr(self.information, "word" + str(i), word)
|
||||||
|
self.information.step += 1
|
||||||
|
self.save()
|
||||||
|
elif 1 <= self.information.step <= len(WORDS['questions']):
|
||||||
|
questions = list(WORDS['questions'].keys())
|
||||||
|
idx = self.information.step - 1
|
||||||
|
if idx < len(questions):
|
||||||
|
q = questions[idx]
|
||||||
|
setattr(self.information, q, form.cleaned_data[q])
|
||||||
|
self.information.step += 1
|
||||||
|
self.save()
|
||||||
|
else:
|
||||||
|
for i, __ in enumerate(WORDS['stats']):
|
||||||
|
ans = form.cleaned_data.get(f'stat_{i}')
|
||||||
|
if ans is not None:
|
||||||
|
setattr(self.information, f'stat_{i}', ans)
|
||||||
|
self.information.step += 1
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_algorithm_class(cls):
|
||||||
|
return WEISurveyAlgorithm2025
|
||||||
|
|
||||||
|
def is_complete(self) -> bool:
|
||||||
|
"""
|
||||||
|
The survey is complete once the bus is chosen.
|
||||||
|
"""
|
||||||
|
return self.information.step > len(WORDS['questions']) + 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@lru_cache()
|
||||||
|
def word_mean(cls, word):
|
||||||
|
"""
|
||||||
|
Calculate the mid-score given by all buses.
|
||||||
|
"""
|
||||||
|
buses = cls.get_algorithm_class().get_buses()
|
||||||
|
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def score_questions(self, bus):
|
||||||
|
"""
|
||||||
|
The score given by the answers to the questions
|
||||||
|
"""
|
||||||
|
if not self.is_complete():
|
||||||
|
raise ValueError("Survey is not ended, can't calculate score")
|
||||||
|
s = sum(1 for q in WORDS['questions'] if q != 'alcool' and getattr(self.information, q) == bus.pk)
|
||||||
|
if 'alcool' in WORDS['questions'] and bus.pk in WORDS['questions']['alcool'][1] and hasattr(self.information, 'alcool'):
|
||||||
|
s -= abs(float(self.information.alcool) - float(WORDS['questions']['alcool'][1][bus.pk]))
|
||||||
|
return s
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def score_words(self, bus):
|
||||||
|
"""
|
||||||
|
The score given by the choice of words
|
||||||
|
"""
|
||||||
|
if not self.is_complete():
|
||||||
|
raise ValueError("Survey is not ended, can't calculate score")
|
||||||
|
|
||||||
|
bus_info = self.get_algorithm_class().get_bus_information(bus)
|
||||||
|
# Score is the given score by the bus subtracted to the mid-score of the buses.
|
||||||
|
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
|
||||||
|
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 1 + NB_WORDS)) / self.get_algorithm_class().get_buses().count()
|
||||||
|
return s
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def scores_per_bus(self):
|
||||||
|
return {bus: (self.score_questions(bus), self.score_words(bus)) for bus in self.get_algorithm_class().get_buses()}
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def ordered_buses(self):
|
||||||
|
"""
|
||||||
|
Order the buses by the score_questions of the survey.
|
||||||
|
"""
|
||||||
|
values = list(self.scores_per_bus().items())
|
||||||
|
values.sort(key=lambda item: -item[1][0])
|
||||||
|
return values
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_cache(cls):
|
||||||
|
cls.word_mean.cache_clear()
|
||||||
|
return super().clear_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
|
||||||
|
"""
|
||||||
|
The algorithm class for the year 2025.
|
||||||
|
We use Gale-Shapley algorithm to attribute 1y students into buses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_survey_class(cls):
|
||||||
|
return WEISurvey2025
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_bus_information_class(cls):
|
||||||
|
return WEIBusInformation2025
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_bus_information_form(cls):
|
||||||
|
return BusInformationForm2025
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_buses(cls):
|
||||||
|
|
||||||
|
if not hasattr(cls, '_buses'):
|
||||||
|
cls._buses = Bus.objects.filter(wei__year=cls.get_survey_class().get_year(), size__gt=0).all().exclude(name='Staff')
|
||||||
|
return cls._buses
|
||||||
|
|
||||||
|
def run_algorithm(self, display_tqdm=False):
|
||||||
|
"""
|
||||||
|
Gale-Shapley algorithm implementation.
|
||||||
|
We modify it to allow buses to have multiple "weddings".
|
||||||
|
We use lexigographical order on both scores
|
||||||
|
"""
|
||||||
|
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
|
||||||
|
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
|
||||||
|
# Don't manage hardcoded people
|
||||||
|
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
|
||||||
|
|
||||||
|
# Reset previous algorithm run
|
||||||
|
for survey in surveys:
|
||||||
|
survey.free()
|
||||||
|
survey.save()
|
||||||
|
|
||||||
|
non_men = [s for s in surveys if s.registration.gender != 'male']
|
||||||
|
men = [s for s in surveys if s.registration.gender == 'male']
|
||||||
|
|
||||||
|
quotas = {}
|
||||||
|
registrations = self.get_registrations()
|
||||||
|
non_men_total = registrations.filter(~Q(gender='male')).count()
|
||||||
|
for bus in self.get_buses():
|
||||||
|
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||||
|
# Remove hardcoded people
|
||||||
|
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||||
|
registration__information_json__icontains="hardcoded").count()
|
||||||
|
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
|
||||||
|
|
||||||
|
tqdm_obj = None
|
||||||
|
if display_tqdm:
|
||||||
|
from tqdm import tqdm
|
||||||
|
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
|
||||||
|
|
||||||
|
# Repartition for non men people first
|
||||||
|
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
|
||||||
|
|
||||||
|
quotas = {}
|
||||||
|
for bus in self.get_buses():
|
||||||
|
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||||
|
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
|
||||||
|
# Remove hardcoded people
|
||||||
|
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||||
|
registration__information_json__icontains="hardcoded").count()
|
||||||
|
quotas[bus] = free_seats
|
||||||
|
|
||||||
|
if display_tqdm:
|
||||||
|
tqdm_obj.close()
|
||||||
|
|
||||||
|
from tqdm import tqdm
|
||||||
|
tqdm_obj = tqdm(total=len(men), desc="Hommes")
|
||||||
|
|
||||||
|
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
|
||||||
|
|
||||||
|
if display_tqdm:
|
||||||
|
tqdm_obj.close()
|
||||||
|
|
||||||
|
# Clear cache information after running algorithm
|
||||||
|
WEISurvey2025.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_scores 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_words(bus)
|
||||||
|
if current_scores[1] <= 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/0014_weiclub_fee_soge_credit.py
Normal file
18
apps/wei/migrations/0014_weiclub_fee_soge_credit.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.23 on 2025-07-15 14:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wei', '0013_weiclub_caution_amount_weiregistration_caution_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='weiclub',
|
||||||
|
name='fee_soge_credit',
|
||||||
|
field=models.PositiveIntegerField(default=2000, verbose_name='fee soge credit'),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 4.2.23 on 2025-07-15 16:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wei', '0014_weiclub_fee_soge_credit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='weiclub',
|
||||||
|
name='caution_amount',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='caution_check',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='caution_type',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='weiclub',
|
||||||
|
name='deposit_amount',
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name='deposit amount'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='deposit_check',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Deposit check given'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='deposit_type',
|
||||||
|
field=models.CharField(choices=[('check', 'Check'), ('note', 'Note transaction')], default='check', max_length=16, verbose_name='deposit type'),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-19 12:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wei', '0015_remove_weiclub_caution_amount_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='fee',
|
||||||
|
field=models.PositiveIntegerField(blank=True, default=0, verbose_name='fee'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='weiclub',
|
||||||
|
name='fee_soge_credit',
|
||||||
|
field=models.PositiveIntegerField(default=2000, verbose_name='membership fee (soge credit)'),
|
||||||
|
),
|
||||||
|
]
|
18
apps/wei/migrations/0017_alter_weiclub_fee_soge_credit.py
Normal file
18
apps/wei/migrations/0017_alter_weiclub_fee_soge_credit.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-02 13:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wei', '0016_weiregistration_fee_alter_weiclub_fee_soge_credit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='weiclub',
|
||||||
|
name='fee_soge_credit',
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name='membership fee (soge credit)'),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-02 17:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wei', '0017_alter_weiclub_fee_soge_credit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='deposit_check',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='weiregistration',
|
||||||
|
name='deposit_given',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Deposit given'),
|
||||||
|
),
|
||||||
|
]
|
@@ -33,8 +33,13 @@ class WEIClub(Club):
|
|||||||
verbose_name=_("date end"),
|
verbose_name=_("date end"),
|
||||||
)
|
)
|
||||||
|
|
||||||
caution_amount = models.PositiveIntegerField(
|
deposit_amount = models.PositiveIntegerField(
|
||||||
verbose_name=_("caution amount"),
|
verbose_name=_("deposit amount"),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
fee_soge_credit = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("membership fee (soge credit)"),
|
||||||
default=0,
|
default=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -197,19 +202,19 @@ class WEIRegistration(models.Model):
|
|||||||
verbose_name=_("Credit from Société générale"),
|
verbose_name=_("Credit from Société générale"),
|
||||||
)
|
)
|
||||||
|
|
||||||
caution_check = models.BooleanField(
|
deposit_given = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Caution check given")
|
verbose_name=_("Deposit given")
|
||||||
)
|
)
|
||||||
|
|
||||||
caution_type = models.CharField(
|
deposit_type = models.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
choices=(
|
choices=(
|
||||||
('check', _("Check")),
|
('check', _("Check")),
|
||||||
('note', _("Note transaction")),
|
('note', _("Note transaction")),
|
||||||
),
|
),
|
||||||
default='check',
|
default='check',
|
||||||
verbose_name=_("caution type"),
|
verbose_name=_("deposit type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
birth_date = models.DateField(
|
birth_date = models.DateField(
|
||||||
@@ -280,6 +285,12 @@ class WEIRegistration(models.Model):
|
|||||||
"encoded in JSON"),
|
"encoded in JSON"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fee = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name=_('fee'),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('user', 'wei',)
|
unique_together = ('user', 'wei',)
|
||||||
verbose_name = _("WEI User")
|
verbose_name = _("WEI User")
|
||||||
@@ -304,7 +315,25 @@ class WEIRegistration(models.Model):
|
|||||||
self.information_json = json.dumps(information, indent=2)
|
self.information_json = json.dumps(information, indent=2)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fee(self):
|
def is_validated(self):
|
||||||
|
try:
|
||||||
|
return self.membership is not None
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validation_status(self):
|
||||||
|
"""
|
||||||
|
Define an order to have easier access to validatable registrations
|
||||||
|
"""
|
||||||
|
if self.fee + (self.wei.deposit_amount if self.deposit_type == 'note' else 0) > self.user.note.balance:
|
||||||
|
return 2
|
||||||
|
elif self.first_year:
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def calculate_fee(self):
|
||||||
bde = Club.objects.get(pk=1)
|
bde = Club.objects.get(pk=1)
|
||||||
kfet = Club.objects.get(pk=2)
|
kfet = Club.objects.get(pk=2)
|
||||||
|
|
||||||
@@ -319,7 +348,8 @@ class WEIRegistration(models.Model):
|
|||||||
date_start__gte=bde.membership_start,
|
date_start__gte=bde.membership_start,
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
fee = self.wei.membership_fee_paid if self.user.profile.paid \
|
fee = self.wei.fee_soge_credit if self.soge_credit \
|
||||||
|
else self.wei.membership_fee_paid if self.user.profile.paid \
|
||||||
else self.wei.membership_fee_unpaid
|
else self.wei.membership_fee_unpaid
|
||||||
if not kfet_member:
|
if not kfet_member:
|
||||||
fee += kfet.membership_fee_paid if self.user.profile.paid \
|
fee += kfet.membership_fee_paid if self.user.profile.paid \
|
||||||
@@ -330,12 +360,9 @@ class WEIRegistration(models.Model):
|
|||||||
|
|
||||||
return fee
|
return fee
|
||||||
|
|
||||||
@property
|
def save(self, *args, **kwargs):
|
||||||
def is_validated(self):
|
self.fee = self.calculate_fee()
|
||||||
try:
|
super().save(*args, **kwargs)
|
||||||
return self.membership is not None
|
|
||||||
except AttributeError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class WEIMembership(Membership):
|
class WEIMembership(Membership):
|
||||||
|
BIN
apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
Normal file
BIN
apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
@@ -58,8 +58,8 @@ class WEIRegistrationTable(tables.Table):
|
|||||||
|
|
||||||
validate = tables.Column(
|
validate = tables.Column(
|
||||||
verbose_name=_("Validate"),
|
verbose_name=_("Validate"),
|
||||||
orderable=False,
|
orderable=True,
|
||||||
accessor=A('pk'),
|
accessor='validate_status',
|
||||||
attrs={
|
attrs={
|
||||||
'th': {
|
'th': {
|
||||||
'id': 'validate-membership-header'
|
'id': 'validate-membership-header'
|
||||||
@@ -71,7 +71,7 @@ class WEIRegistrationTable(tables.Table):
|
|||||||
'wei:wei_delete_registration',
|
'wei:wei_delete_registration',
|
||||||
args=[A('pk')],
|
args=[A('pk')],
|
||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name=_("delete"),
|
verbose_name=_("Delete"),
|
||||||
text=_("Delete"),
|
text=_("Delete"),
|
||||||
attrs={
|
attrs={
|
||||||
'th': {
|
'th': {
|
||||||
@@ -84,6 +84,35 @@ class WEIRegistrationTable(tables.Table):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def render_deposit_type(self, record):
|
||||||
|
if record.first_year:
|
||||||
|
return format_html("∅")
|
||||||
|
if record.deposit_type == 'check':
|
||||||
|
# TODO Install Font Awesome 6 to acces more icons (and keep compaibility with current used v4)
|
||||||
|
return format_html("""
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5em" height="1.5em"
|
||||||
|
fill="currentColor" style="position: relative; left: -0.15em;">
|
||||||
|
<path d="
|
||||||
|
M128 128C92.7 128 64 156.7 64 192L64 448C64 483.3 92.7 512 128 512L512 512
|
||||||
|
C547.3 512 576 483.3 576 448L576 192C576 156.7 547.3 128 512 128L128 128z
|
||||||
|
M360 352L488 352C501.3 352 512 362.7 512 376C512 389.3 501.3 400 488 400L360 400
|
||||||
|
C346.7 400 336 389.3 336 376C336 362.7 346.7 352 360 352z
|
||||||
|
M336 264C336 250.7 346.7 240 360 240L488 240C501.3 240 512 250.7 512 264
|
||||||
|
C512 277.3 501.3 288 488 288L360 288C346.7 288 336 277.3 336 264z
|
||||||
|
M212 208C223 208 232 217 232 228L232 232L240 232C251 232 260 241 260 252
|
||||||
|
C260 263 251 272 240 272L192.5 272C185.6 272 180 277.6 180 284.5
|
||||||
|
C180 290.6 184.4 295.8 190.4 296.8L232.1 303.8C257.4 308 276 329.9 276 355.6
|
||||||
|
C276 381.7 257 403.3 232 407.4L232 412.1C232 423.1 223 432.1 212 432.1
|
||||||
|
C201 432.1 192 423.1 192 412.1L192 408.1L168 408.1C157 408.1 148 399.1 148 388.1
|
||||||
|
C148 377.1 157 368.1 168 368.1L223.5 368.1C230.4 368.1 236 362.5 236 355.6
|
||||||
|
C236 349.5 231.6 344.3 225.6 343.3L183.9 336.3C158.5 332 140 310.1 140 284.5
|
||||||
|
C140 255.7 163.2 232.3 192 232L192 228C192 217 201 208 212 208z
|
||||||
|
" />
|
||||||
|
</svg>
|
||||||
|
""")
|
||||||
|
if record.deposit_type == 'note':
|
||||||
|
return format_html("<i class=\"fa fa-exchange\"></i>")
|
||||||
|
|
||||||
def render_validate(self, record):
|
def render_validate(self, record):
|
||||||
hasperm = PermissionBackend.check_perm(
|
hasperm = PermissionBackend.check_perm(
|
||||||
get_current_request(), "wei.add_weimembership", WEIMembership(
|
get_current_request(), "wei.add_weimembership", WEIMembership(
|
||||||
@@ -98,12 +127,13 @@ class WEIRegistrationTable(tables.Table):
|
|||||||
if not hasperm:
|
if not hasperm:
|
||||||
return format_html("<span class='no-perm'></span>")
|
return format_html("<span class='no-perm'></span>")
|
||||||
|
|
||||||
url = reverse_lazy('wei:wei_update_registration', args=(record.pk,)) + '?validate=true'
|
url = reverse_lazy('wei:validate_registration', args=(record.pk,))
|
||||||
text = _('Validate')
|
text = _('Validate')
|
||||||
if record.fee > record.user.note.balance and not record.soge_credit:
|
status = record.validation_status
|
||||||
|
if status == 2:
|
||||||
btn_class = 'btn-secondary'
|
btn_class = 'btn-secondary'
|
||||||
tooltip = _("The user does not have enough money.")
|
tooltip = _("The user does not have enough money.")
|
||||||
elif record.first_year:
|
elif status == 1:
|
||||||
btn_class = 'btn-info'
|
btn_class = 'btn-info'
|
||||||
tooltip = _("The user is in first year. You may validate the credit, the algorithm will run later.")
|
tooltip = _("The user is in first year. You may validate the credit, the algorithm will run later.")
|
||||||
else:
|
else:
|
||||||
@@ -121,10 +151,11 @@ class WEIRegistrationTable(tables.Table):
|
|||||||
attrs = {
|
attrs = {
|
||||||
'class': 'table table-condensed table-striped table-hover'
|
'class': 'table table-condensed table-striped table-hover'
|
||||||
}
|
}
|
||||||
|
order_by = ('validate', 'user',)
|
||||||
model = WEIRegistration
|
model = WEIRegistration
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'caution_check',
|
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'deposit_given',
|
||||||
'edit', 'validate', 'delete',)
|
'deposit_type', 'edit', 'validate', 'delete',)
|
||||||
row_attrs = {
|
row_attrs = {
|
||||||
'class': 'table-row',
|
'class': 'table-row',
|
||||||
'id': lambda record: "row-" + str(record.pk),
|
'id': lambda record: "row-" + str(record.pk),
|
||||||
@@ -134,8 +165,8 @@ class WEIRegistrationTable(tables.Table):
|
|||||||
|
|
||||||
class WEIMembershipTable(tables.Table):
|
class WEIMembershipTable(tables.Table):
|
||||||
user = tables.LinkColumn(
|
user = tables.LinkColumn(
|
||||||
'wei:wei_update_registration',
|
'wei:wei_update_membership',
|
||||||
args=[A('registration__pk')],
|
args=[A('pk')],
|
||||||
)
|
)
|
||||||
|
|
||||||
year = tables.Column(
|
year = tables.Column(
|
||||||
@@ -156,6 +187,35 @@ class WEIMembershipTable(tables.Table):
|
|||||||
def render_year(self, record):
|
def render_year(self, record):
|
||||||
return str(record.user.profile.ens_year) + "A"
|
return str(record.user.profile.ens_year) + "A"
|
||||||
|
|
||||||
|
def render_registration__deposit_type(self, record):
|
||||||
|
if record.registration.first_year:
|
||||||
|
return format_html("∅")
|
||||||
|
if record.registration.deposit_type == 'check':
|
||||||
|
# TODO Install Font Awesome 6 to acces more icons (and keep compaibility with current used v4)
|
||||||
|
return format_html("""
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5em" height="1.5em"
|
||||||
|
fill="currentColor" style="position: relative; left: -0.15em;">
|
||||||
|
<path d="
|
||||||
|
M128 128C92.7 128 64 156.7 64 192L64 448C64 483.3 92.7 512 128 512L512 512
|
||||||
|
C547.3 512 576 483.3 576 448L576 192C576 156.7 547.3 128 512 128L128 128z
|
||||||
|
M360 352L488 352C501.3 352 512 362.7 512 376C512 389.3 501.3 400 488 400L360 400
|
||||||
|
C346.7 400 336 389.3 336 376C336 362.7 346.7 352 360 352z
|
||||||
|
M336 264C336 250.7 346.7 240 360 240L488 240C501.3 240 512 250.7 512 264
|
||||||
|
C512 277.3 501.3 288 488 288L360 288C346.7 288 336 277.3 336 264z
|
||||||
|
M212 208C223 208 232 217 232 228L232 232L240 232C251 232 260 241 260 252
|
||||||
|
C260 263 251 272 240 272L192.5 272C185.6 272 180 277.6 180 284.5
|
||||||
|
C180 290.6 184.4 295.8 190.4 296.8L232.1 303.8C257.4 308 276 329.9 276 355.6
|
||||||
|
C276 381.7 257 403.3 232 407.4L232 412.1C232 423.1 223 432.1 212 432.1
|
||||||
|
C201 432.1 192 423.1 192 412.1L192 408.1L168 408.1C157 408.1 148 399.1 148 388.1
|
||||||
|
C148 377.1 157 368.1 168 368.1L223.5 368.1C230.4 368.1 236 362.5 236 355.6
|
||||||
|
C236 349.5 231.6 344.3 225.6 343.3L183.9 336.3C158.5 332 140 310.1 140 284.5
|
||||||
|
C140 255.7 163.2 232.3 192 232L192 228C192 217 201 208 212 208z
|
||||||
|
" />
|
||||||
|
</svg>
|
||||||
|
""")
|
||||||
|
if record.registration.deposit_type == 'note':
|
||||||
|
return format_html("<i class=\"fa fa-exchange\"></i>")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
attrs = {
|
attrs = {
|
||||||
'class': 'table table-condensed table-striped table-hover'
|
'class': 'table table-condensed table-striped table-hover'
|
||||||
@@ -163,7 +223,7 @@ class WEIMembershipTable(tables.Table):
|
|||||||
model = WEIMembership
|
model = WEIMembership
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
fields = ('user', 'user__last_name', 'user__first_name', 'registration__gender', 'user__profile__department',
|
fields = ('user', 'user__last_name', 'user__first_name', 'registration__gender', 'user__profile__department',
|
||||||
'year', 'bus', 'team', 'registration__caution_check', )
|
'year', 'bus', 'team', 'registration__deposit_given', 'registration__deposit_type')
|
||||||
row_attrs = {
|
row_attrs = {
|
||||||
'class': 'table-row',
|
'class': 'table-row',
|
||||||
'id': lambda record: "row-" + str(record.pk),
|
'id': lambda record: "row-" + str(record.pk),
|
||||||
|
@@ -49,9 +49,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if club.caution_amount > 0 %}
|
{% if club.deposit_amount > 0 %}
|
||||||
<dt class="col-xl-6">{% trans 'Caution amount'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'deposit amount'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ club.caution_amount|pretty_money }}</dd>
|
<dd class="col-xl-6">{{ club.deposit_amount|pretty_money }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if "note.view_note"|has_perm:club.note %}
|
{% if "note.view_note"|has_perm:club.note %}
|
||||||
|
@@ -22,6 +22,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=object.pk %}"
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=object.pk %}"
|
||||||
data-turbolinks="false">{% trans "Edit" %}</a>
|
data-turbolinks="false">{% trans "Edit" %}</a>
|
||||||
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus_info' pk=object.pk %}"
|
||||||
|
data-turbolinks="false">{% trans "Edit information for survey" %}</a>
|
||||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=object.pk %}"
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=object.pk %}"
|
||||||
data-turbolinks="false">{% trans "Add team" %}</a>
|
data-turbolinks="false">{% trans "Add team" %}</a>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -31,15 +31,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<a class="btn btn-success" href="{% url "wei:wei_register_1A_myself" wei_pk=club.pk %}" data-turbolinks="false">
|
<a class="btn btn-success" href="{% url "wei:wei_register_1A_myself" wei_pk=club.pk %}" data-turbolinks="false">
|
||||||
{% trans "Register to the WEI! – 1A" %}
|
{% trans "Register to the WEI! – 1A" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
<a class="btn btn-success" href="{% url "wei:wei_register_2A_myself" wei_pk=club.pk %}" data-turbolinks="false">
|
|
||||||
{% trans "Register to the WEI! – 2A+" %}</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<a class="btn btn-success" href="{% url "wei:wei_register_2A_myself" wei_pk=club.pk %}" data-turbolinks="false">
|
||||||
|
{% trans "Register to the WEI! – 2A+" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if registration.validated %}
|
||||||
<a class="btn btn-warning" href="{% url "wei:wei_update_registration" pk=my_registration.pk %}"
|
<a class="btn btn-warning" href="{% url "wei:wei_update_registration" pk=my_registration.pk %}"
|
||||||
data-turbolinks="false">
|
data-turbolinks="false">
|
||||||
{% trans "Update my registration" %}
|
{% trans "Update my registration" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if my_registration.first_year %}
|
||||||
|
{% if not survey_complete %}
|
||||||
|
<a class="btn btn-warning" href="{% url "wei:wei_survey" pk=my_registration.pk %}" data-turbolinks="false">
|
||||||
|
{% trans "Continue survey" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-warning" href="{% url "wei:wei_survey" pk=my_registration.pk %}?reset=true" data-turbolinks="false">
|
||||||
|
{% trans "Restart survey" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,20 +81,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if history_list.data %}
|
|
||||||
<div class="card bg-white mb-3">
|
|
||||||
<div class="card-header position-relative" id="historyListHeading">
|
|
||||||
<a class="stretched-link font-weight-bold text-decoration-none" {% if "note.view_note"|has_perm:club.note %}
|
|
||||||
href="{% url 'note:transactions' pk=club.note.pk %}" {% endif %}>
|
|
||||||
<i class="fa fa-euro"></i> {% trans "Transaction history" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div id="history_list">
|
|
||||||
{% render_table history_list %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if pre_registrations.data %}
|
{% if pre_registrations.data %}
|
||||||
<div class="card bg-white mb-3">
|
<div class="card bg-white mb-3">
|
||||||
<div class="card-header position-relative" id="historyListHeading">
|
<div class="card-header position-relative" id="historyListHeading">
|
||||||
@@ -99,6 +99,19 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if history_list.data %}
|
||||||
|
<div class="card bg-white mt-3">
|
||||||
|
<div class="card-header position-relative" id="historyListHeading">
|
||||||
|
<a class="stretched-link font-weight-bold text-decoration-none" {% if "note.view_note"|has_perm:club.note %}
|
||||||
|
href="{% url 'note:transactions' pk=club.note.pk %}" {% endif %}>
|
||||||
|
<i class="fa fa-euro"></i> {% trans "Transaction history" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div id="history_list">
|
||||||
|
{% render_table history_list %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -95,8 +95,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<dd class="col-xl-6"><em>{% trans "The algorithm didn't run." %}</em></dd>
|
<dd class="col-xl-6"><em>{% trans "The algorithm didn't run." %}</em></dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<dt class="col-xl-6">{% trans 'caution check given'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'Deposit check given'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ registration.caution_check|yesno }}</dd>
|
<dd class="col-xl-6">{{ registration.deposit_given|yesno }}</dd>
|
||||||
|
|
||||||
{% with information=registration.information %}
|
{% with information=registration.information %}
|
||||||
<dt class="col-xl-6">{% trans 'preferred bus'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'preferred bus'|capfirst %}</dt>
|
||||||
@@ -137,43 +137,41 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% if registration.soge_credit %}
|
{% if registration.soge_credit %}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
The WEI will be paid by Société générale. The membership will be created even if the bank didn't pay the BDE yet.
|
The WEI will partially be paid by Société générale. The membership will be created even if the bank didn't pay the BDE yet.
|
||||||
The membership transaction will be created but will be invalid. You will have to validate it once the bank
|
The membership transaction will be created but will be invalid. You will have to validate it once the bank
|
||||||
validated the creation of the account, or to change the payment method.
|
validated the creation of the account, or to change the payment method.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
<div class="alert {% if registration.user.note.balance < fee %}alert-danger{% else %}alert-success{% endif %}">
|
|
||||||
<h5>{% trans "Required payments:" %}</h5>
|
|
||||||
<ul>
|
|
||||||
<li>{% blocktrans trimmed with amount=fee|pretty_money %}
|
|
||||||
Membership fees: {{ amount }}
|
|
||||||
{% endblocktrans %}</li>
|
|
||||||
{% if registration.caution_type == 'note' %}
|
|
||||||
<li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
|
|
||||||
Deposit (by Note transaction): {{ amount }}
|
|
||||||
{% endblocktrans %}</li>
|
|
||||||
<li><strong>{% blocktrans trimmed with total=total_needed|pretty_money %}
|
|
||||||
Total needed: {{ total }}
|
|
||||||
{% endblocktrans %}</strong></li>
|
|
||||||
{% else %}
|
|
||||||
<li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
|
|
||||||
Deposit (by check): {{ amount }}
|
|
||||||
{% endblocktrans %}</li>
|
|
||||||
<li><strong>{% blocktrans trimmed with total=fee|pretty_money %}
|
|
||||||
Total needed: {{ total }}
|
|
||||||
{% endblocktrans %}</strong></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
<p>{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
|
|
||||||
Current balance: {{ balance }}
|
|
||||||
{% endblocktrans %}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="alert {% if registration.validation_status == 2 %}alert-danger{% else %}alert-success{% endif %}">
|
||||||
|
<h5>{% trans "Required payments:" %}</h5>
|
||||||
|
<ul>
|
||||||
|
<li>{% blocktrans trimmed with amount=fee|pretty_money %}
|
||||||
|
Membership fees: {{ amount }}
|
||||||
|
{% endblocktrans %}</li>
|
||||||
|
{% if not registration.first_year %}
|
||||||
|
{% if registration.deposit_type == 'note' %}
|
||||||
|
<li>{% blocktrans trimmed with amount=club.deposit_amount|pretty_money %}
|
||||||
|
Deposit (by Note transaction): {{ amount }}
|
||||||
|
{% endblocktrans %}</li>
|
||||||
|
{% else %}
|
||||||
|
<li>{% blocktrans trimmed with amount=club.deposit_amount|pretty_money %}
|
||||||
|
Deposit (by check): {{ amount }}
|
||||||
|
{% endblocktrans %}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<li><strong>{% blocktrans trimmed with total=total_needed|pretty_money %}
|
||||||
|
Total needed: {{ total }}
|
||||||
|
{% endblocktrans %}</strong></li>
|
||||||
|
</ul>
|
||||||
|
<p>{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
|
||||||
|
Current balance: {{ balance }}
|
||||||
|
{% endblocktrans %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if not registration.caution_check and not registration.first_year and registration.caution_type == 'check' %}
|
{% if not registration.deposit_given and not registration.first_year and registration.caution_type == 'check' %}
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
{% trans "The user didn't give her/his caution check." %}
|
{% trans "The user didn't give her/his caution." %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -210,4 +208,26 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function () {
|
||||||
|
function refreshTeams() {
|
||||||
|
let buses = [];
|
||||||
|
$("input[name='bus']:checked").each(function (ignored) {
|
||||||
|
buses.push($(this).parent().text().trim());
|
||||||
|
});
|
||||||
|
$("input[name='team']").each(function () {
|
||||||
|
let label = $(this).parent();
|
||||||
|
$(this).parent().addClass('d-none');
|
||||||
|
buses.forEach(function (bus) {
|
||||||
|
if (label.text().includes(bus))
|
||||||
|
label.removeClass('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("input[name='bus']").change(refreshTeams);
|
||||||
|
|
||||||
|
refreshTeams();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
46
apps/wei/templates/wei/weimembership_update.html
Normal file
46
apps/wei/templates/wei/weimembership_update.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
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 %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function () {
|
||||||
|
function refreshTeams() {
|
||||||
|
let buses = [];
|
||||||
|
$("input[name='bus']:checked").each(function (ignored) {
|
||||||
|
buses.push($(this).parent().text().trim());
|
||||||
|
});
|
||||||
|
$("input[name='team']").each(function () {
|
||||||
|
let label = $(this).parent();
|
||||||
|
$(this).parent().addClass('d-none');
|
||||||
|
buses.forEach(function (bus) {
|
||||||
|
if (label.text().includes(bus))
|
||||||
|
label.removeClass('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("input[name='bus']").change(refreshTeams);
|
||||||
|
|
||||||
|
refreshTeams();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form id="registration-form" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
{{ membership_form|crispy }}
|
{{ membership_form|crispy }}
|
||||||
@@ -22,6 +22,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
|
<!-- intl-tel-input CSS/JS -->
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const input = document.querySelector("input[name='emergency_contact_phone']");
|
||||||
|
const form = document.querySelector("#registration-form");
|
||||||
|
|
||||||
|
if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iti = window.intlTelInput(input, {
|
||||||
|
initialCountry: "auto",
|
||||||
|
nationalMode: false,
|
||||||
|
autoPlaceholder: "off",
|
||||||
|
geoIpLookup: callback => {
|
||||||
|
fetch("https://ipapi.co/json")
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => callback(data.country_code))
|
||||||
|
.catch(() => callback("fr"));
|
||||||
|
},
|
||||||
|
loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", function(e){
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = iti.getNumber(intlTelInput.utils.numberFormat.E164);
|
||||||
|
if (number) {
|
||||||
|
input.value = number;
|
||||||
|
form.submit();
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% if not object.membership %}
|
{% if not object.membership %}
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
@@ -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.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024
|
from ..forms.surveys.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024
|
||||||
from ..models import Bus, WEIClub, WEIRegistration
|
from ..models import Bus, WEIClub, WEIRegistration
|
||||||
@@ -129,44 +127,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, 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())
|
|
||||||
|
129
apps/wei/tests/test_wei_algorithm_2025.py
Normal file
129
apps/wei/tests/test_wei_algorithm_2025.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from ..forms.surveys.wei2025 import WEIBusInformation2025, WEISurvey2025, WORDS, NB_WORDS, WEISurveyInformation2025
|
||||||
|
from ..models import Bus, WEIClub, WEIRegistration
|
||||||
|
|
||||||
|
|
||||||
|
class TestWEIAlgorithm(TestCase):
|
||||||
|
"""
|
||||||
|
Run some tests to ensure that the WEI algorithm is working well.
|
||||||
|
"""
|
||||||
|
fixtures = ('initial',)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""
|
||||||
|
Create some test data, with one WEI and 10 buses with random score attributions.
|
||||||
|
"""
|
||||||
|
self.wei = WEIClub.objects.create(
|
||||||
|
name="WEI 2025",
|
||||||
|
email="wei2025@example.com",
|
||||||
|
date_start='2025-09-12',
|
||||||
|
date_end='2025-09-14',
|
||||||
|
year=2025,
|
||||||
|
membership_start='2025-06-01'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.buses = []
|
||||||
|
for i in range(8):
|
||||||
|
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
|
||||||
|
self.buses.append(bus)
|
||||||
|
information = WEIBusInformation2025(bus)
|
||||||
|
for word in WORDS['list']:
|
||||||
|
information.scores[word] = random.randint(0, 6)
|
||||||
|
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 = WEISurveyInformation2025(registration)
|
||||||
|
for j in range(1, 1 + NB_WORDS):
|
||||||
|
setattr(information, f'word{j}', random.choice(WORDS['list']))
|
||||||
|
for q in WORDS['questions']:
|
||||||
|
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
|
||||||
|
information.step = len(WORDS['questions']) + 2
|
||||||
|
information.save(registration)
|
||||||
|
registration.save()
|
||||||
|
|
||||||
|
# Run algorithm
|
||||||
|
WEISurvey2025.get_algorithm_class()().run_algorithm()
|
||||||
|
|
||||||
|
# Ensure that everyone has its first choice
|
||||||
|
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||||
|
survey = WEISurvey2025(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(80):
|
||||||
|
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 = WEISurveyInformation2025(registration)
|
||||||
|
for j in range(1, 1 + NB_WORDS):
|
||||||
|
setattr(information, f'word{j}', random.choice(WORDS['list']))
|
||||||
|
for q in WORDS['questions']:
|
||||||
|
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
|
||||||
|
information.step = len(WORDS['questions']) + 2
|
||||||
|
information.save(registration)
|
||||||
|
registration.save()
|
||||||
|
survey = WEISurvey2025(registration)
|
||||||
|
|
||||||
|
# Run algorithm
|
||||||
|
WEISurvey2025.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 = WEISurvey2025(r)
|
||||||
|
chosen_bus = survey.information.get_selected_bus()
|
||||||
|
buses = survey.ordered_buses()
|
||||||
|
self.assertIn(chosen_bus, [x[0] for x in buses])
|
||||||
|
score_questions, score_words = next(scores for bus, scores in buses if bus == chosen_bus)
|
||||||
|
max_score_questions = max(buses[i][1][0] for i in range(len(buses)))
|
||||||
|
max_score_words = max(buses[i][1][1] for i in range(len(buses)))
|
||||||
|
penalty += (max_score_words - score_words) ** 2
|
||||||
|
penalty += (max_score_questions - score_questions) ** 2
|
||||||
|
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
||||||
|
|
||||||
|
# There shouldn't be users who would prefer to switch buses
|
||||||
|
for r1 in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||||
|
survey1 = WEISurvey2025(r1)
|
||||||
|
bus1 = survey1.information.get_selected_bus()
|
||||||
|
for r2 in WEIRegistration.objects.filter(wei=self.wei, pk__gt=r1.pk):
|
||||||
|
survey2 = WEISurvey2025(r2)
|
||||||
|
bus2 = survey2.information.get_selected_bus()
|
||||||
|
|
||||||
|
prefer_switch_bus_words = survey1.score_words(bus2) > survey1.score_words(bus1) and survey2.score_words(bus1) > survey2.score_words(bus2)
|
||||||
|
prefer_switch_bus_questions = survey1.score_questions(bus2) > survey1.score_questions(bus1) and\
|
||||||
|
survey2.score_questions(bus1) > survey2.score_questions(bus2)
|
||||||
|
self.assertFalse(prefer_switch_bus_words and prefer_switch_bus_questions)
|
@@ -101,7 +101,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
user_id=self.user.id,
|
user_id=self.user.id,
|
||||||
wei_id=self.wei.id,
|
wei_id=self.wei.id,
|
||||||
soge_credit=True,
|
soge_credit=True,
|
||||||
caution_check=True,
|
deposit_given=True,
|
||||||
birth_date=date(2000, 1, 1),
|
birth_date=date(2000, 1, 1),
|
||||||
gender="nonbinary",
|
gender="nonbinary",
|
||||||
clothing_cut="male",
|
clothing_cut="male",
|
||||||
@@ -121,12 +121,13 @@ class TestWEIRegistration(TestCase):
|
|||||||
email="gc.wei@example.com",
|
email="gc.wei@example.com",
|
||||||
membership_fee_paid=12500,
|
membership_fee_paid=12500,
|
||||||
membership_fee_unpaid=5500,
|
membership_fee_unpaid=5500,
|
||||||
|
fee_soge_credit=2000,
|
||||||
membership_start=str(self.year + 1) + "-08-01",
|
membership_start=str(self.year + 1) + "-08-01",
|
||||||
membership_end=str(self.year + 1) + "-09-30",
|
membership_end=str(self.year + 1) + "-09-30",
|
||||||
year=self.year + 1,
|
year=self.year + 1,
|
||||||
date_start=str(self.year + 1) + "-09-01",
|
date_start=str(self.year + 1) + "-09-01",
|
||||||
date_end=str(self.year + 1) + "-09-03",
|
date_end=str(self.year + 1) + "-09-03",
|
||||||
caution_amount=12000,
|
deposit_amount=12000,
|
||||||
))
|
))
|
||||||
qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1)
|
qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1)
|
||||||
self.assertTrue(qs.exists())
|
self.assertTrue(qs.exists())
|
||||||
@@ -157,11 +158,12 @@ class TestWEIRegistration(TestCase):
|
|||||||
email="wei-updated@example.com",
|
email="wei-updated@example.com",
|
||||||
membership_fee_paid=0,
|
membership_fee_paid=0,
|
||||||
membership_fee_unpaid=0,
|
membership_fee_unpaid=0,
|
||||||
|
fee_soge_credit=0,
|
||||||
membership_start="2000-08-01",
|
membership_start="2000-08-01",
|
||||||
membership_end="2000-09-30",
|
membership_end="2000-09-30",
|
||||||
date_start="2000-09-01",
|
date_start="2000-09-01",
|
||||||
date_end="2000-09-03",
|
date_end="2000-09-03",
|
||||||
caution_amount=12000,
|
deposit_amount=12000,
|
||||||
))
|
))
|
||||||
qs = WEIClub.objects.filter(name="Update WEI Test", id=self.wei.id)
|
qs = WEIClub.objects.filter(name="Update WEI Test", id=self.wei.id)
|
||||||
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.wei.pk)), 302, 200)
|
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.wei.pk)), 302, 200)
|
||||||
@@ -320,7 +322,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
bus=[],
|
bus=[],
|
||||||
team=[],
|
team=[],
|
||||||
roles=[],
|
roles=[],
|
||||||
caution_type='check'
|
deposit_type='check'
|
||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(response.context["membership_form"].is_valid())
|
self.assertFalse(response.context["membership_form"].is_valid())
|
||||||
@@ -338,7 +340,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
bus=[self.bus.id],
|
bus=[self.bus.id],
|
||||||
team=[self.team.id],
|
team=[self.team.id],
|
||||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")).all()],
|
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")).all()],
|
||||||
caution_type='check'
|
deposit_type='check'
|
||||||
))
|
))
|
||||||
qs = WEIRegistration.objects.filter(user_id=user.id)
|
qs = WEIRegistration.objects.filter(user_id=user.id)
|
||||||
self.assertTrue(qs.exists())
|
self.assertTrue(qs.exists())
|
||||||
@@ -358,7 +360,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
bus=[self.bus.id],
|
bus=[self.bus.id],
|
||||||
team=[self.team.id],
|
team=[self.team.id],
|
||||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()],
|
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()],
|
||||||
caution_type='check'
|
deposit_type='check'
|
||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue("This user is already registered to this WEI." in str(response.context["form"].errors))
|
self.assertTrue("This user is already registered to this WEI." in str(response.context["form"].errors))
|
||||||
@@ -511,7 +513,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
team=[self.team.id],
|
team=[self.team.id],
|
||||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||||
information_json=self.registration.information_json,
|
information_json=self.registration.information_json,
|
||||||
caution_type='check'
|
deposit_type='check'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M")
|
qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M")
|
||||||
@@ -566,7 +568,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
team=[self.team.id],
|
team=[self.team.id],
|
||||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||||
information_json=self.registration.information_json,
|
information_json=self.registration.information_json,
|
||||||
caution_type='check'
|
deposit_type='check'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L")
|
qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L")
|
||||||
@@ -590,7 +592,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
team=[],
|
team=[],
|
||||||
roles=[],
|
roles=[],
|
||||||
information_json=self.registration.information_json,
|
information_json=self.registration.information_json,
|
||||||
caution_type='check'
|
deposit_type='check'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertFalse(response.context["membership_form"].is_valid())
|
self.assertFalse(response.context["membership_form"].is_valid())
|
||||||
@@ -640,7 +642,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
last_name="admin",
|
last_name="admin",
|
||||||
first_name="admin",
|
first_name="admin",
|
||||||
bank="Société générale",
|
bank="Société générale",
|
||||||
caution_check=True,
|
deposit_given=True,
|
||||||
))
|
))
|
||||||
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())
|
||||||
@@ -655,7 +657,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
last_name="admin",
|
last_name="admin",
|
||||||
first_name="admin",
|
first_name="admin",
|
||||||
bank="Société générale",
|
bank="Société générale",
|
||||||
caution_check=True,
|
deposit_given=True,
|
||||||
))
|
))
|
||||||
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
|
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
|
||||||
|
|
||||||
@@ -678,11 +680,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
self.assertTrue(soge_credit.exists())
|
self.assertTrue(soge_credit.exists())
|
||||||
soge_credit = soge_credit.get()
|
soge_credit = soge_credit.get()
|
||||||
self.assertTrue(membership.transaction in soge_credit.transactions.all())
|
self.assertTrue(membership.transaction in soge_credit.transactions.all())
|
||||||
self.assertTrue(kfet_membership.transaction in soge_credit.transactions.all())
|
|
||||||
self.assertTrue(bde_membership.transaction in soge_credit.transactions.all())
|
|
||||||
self.assertFalse(membership.transaction.valid)
|
self.assertFalse(membership.transaction.valid)
|
||||||
self.assertFalse(kfet_membership.transaction.valid)
|
|
||||||
self.assertFalse(bde_membership.transaction.valid)
|
|
||||||
|
|
||||||
# Check that if the WEI is started, we can't update a wei
|
# Check that if the WEI is started, we can't update a wei
|
||||||
self.wei.date_start = date(2000, 1, 1)
|
self.wei.date_start = date(2000, 1, 1)
|
||||||
@@ -778,7 +776,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(), 2024)
|
self.assertEqual(CurrentSurvey.get_year(), 2025)
|
||||||
|
|
||||||
|
|
||||||
class TestWeiAPI(TestAPI):
|
class TestWeiAPI(TestAPI):
|
||||||
@@ -815,7 +813,7 @@ class TestWeiAPI(TestAPI):
|
|||||||
user_id=self.user.id,
|
user_id=self.user.id,
|
||||||
wei_id=self.wei.id,
|
wei_id=self.wei.id,
|
||||||
soge_credit=True,
|
soge_credit=True,
|
||||||
caution_check=True,
|
deposit_given=True,
|
||||||
birth_date=date(2000, 1, 1),
|
birth_date=date(2000, 1, 1),
|
||||||
gender="nonbinary",
|
gender="nonbinary",
|
||||||
clothing_cut="male",
|
clothing_cut="male",
|
||||||
|
@@ -4,10 +4,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
|
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
|
||||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
|
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, BusInformationUpdateView, \
|
||||||
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
|
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
|
||||||
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
|
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
|
||||||
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
|
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView, WEIUpdateMembershipView
|
||||||
|
|
||||||
app_name = 'wei'
|
app_name = 'wei'
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -42,4 +42,7 @@ urlpatterns = [
|
|||||||
path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"),
|
path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"),
|
||||||
path('bus-1A/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
|
path('bus-1A/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
|
||||||
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
|
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
|
||||||
|
path('update-bus-info/<int:pk>/', BusInformationUpdateView.as_view(), name="update_bus_info"),
|
||||||
|
|
||||||
|
path('edit_membership/<int:pk>/', WEIUpdateMembershipView.as_view(), name="wei_update_membership"),
|
||||||
]
|
]
|
||||||
|
@@ -13,7 +13,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q, Count
|
from django.db.models import Q, Count, Case, When, Value, IntegerField, F
|
||||||
from django.db.models.functions.text import Lower
|
from django.db.models.functions.text import Lower
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.http import HttpResponse, Http404
|
from django.http import HttpResponse, Http404
|
||||||
@@ -27,7 +27,7 @@ from django.views.generic.edit import BaseFormView, DeleteView
|
|||||||
from django_tables2 import SingleTableView, MultiTableMixin
|
from django_tables2 import SingleTableView, MultiTableMixin
|
||||||
from api.viewsets import is_regex
|
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
|
||||||
from note.tables import HistoryTable
|
from note.tables import HistoryTable
|
||||||
from note_kfet.settings import BASE_DIR
|
from note_kfet.settings import BASE_DIR
|
||||||
from permission.backends import PermissionBackend
|
from permission.backends import PermissionBackend
|
||||||
@@ -35,7 +35,7 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
|||||||
|
|
||||||
from .forms.registration import WEIChooseBusForm
|
from .forms.registration import WEIChooseBusForm
|
||||||
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
|
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
|
||||||
from .forms import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, BusForm, BusTeamForm, WEIMembership1AForm, \
|
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \
|
||||||
WEIMembershipForm, CurrentSurvey
|
WEIMembershipForm, CurrentSurvey
|
||||||
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
|
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
|
||||||
WEIRegistration1ATable, WEIMembershipTable
|
WEIRegistration1ATable, WEIMembershipTable
|
||||||
@@ -133,6 +133,23 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, D
|
|||||||
membership=None,
|
membership=None,
|
||||||
wei=club
|
wei=club
|
||||||
)
|
)
|
||||||
|
# Annotate the query to be able to sort registrations on validate status
|
||||||
|
pre_registrations = pre_registrations.annotate(
|
||||||
|
deposit=Case(
|
||||||
|
When(deposit_type='note', then=F('wei__deposit_amount')),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
).annotate(
|
||||||
|
total_fee=F('fee') + F('deposit')
|
||||||
|
).annotate(
|
||||||
|
validate_status=Case(
|
||||||
|
When(total_fee__gt=F('user__note__balance'), then=Value(2)),
|
||||||
|
When(first_year=True, then=Value(1)),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
|
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
|
||||||
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
|
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
|
||||||
return [club_transactions, club_member, pre_registrations, buses, ]
|
return [club_transactions, club_member, pre_registrations, buses, ]
|
||||||
@@ -149,6 +166,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, D
|
|||||||
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():
|
||||||
my_registration = my_registration.get()
|
my_registration = my_registration.get()
|
||||||
|
context["survey_complete"] = CurrentSurvey(my_registration).is_complete()
|
||||||
else:
|
else:
|
||||||
my_registration = None
|
my_registration = None
|
||||||
context["my_registration"] = my_registration
|
context["my_registration"] = my_registration
|
||||||
@@ -196,6 +214,8 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, D
|
|||||||
|
|
||||||
context["not_first_year"] = WEIMembership.objects.filter(user=self.request.user).exists()
|
context["not_first_year"] = WEIMembership.objects.filter(user=self.request.user).exists()
|
||||||
|
|
||||||
|
context["registration_validated"] = WEIMembership.objects.filter(registration=my_registration).exists() if my_registration else False
|
||||||
|
|
||||||
qs = WEIMembership.objects.filter(club=club, registration__first_year=True, bus__isnull=True)
|
qs = WEIMembership.objects.filter(club=club, registration__first_year=True, bus__isnull=True)
|
||||||
context["can_validate_1a"] = PermissionBackend.check_perm(
|
context["can_validate_1a"] = PermissionBackend.check_perm(
|
||||||
self.request, "wei.change_weimembership_bus", qs.first()) if qs.exists() else False
|
self.request, "wei.change_weimembership_bus", qs.first()) if qs.exists() else False
|
||||||
@@ -260,6 +280,23 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable
|
|||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
qs = super().get_queryset(**kwargs).filter(wei=self.club, membership=None).distinct()
|
qs = super().get_queryset(**kwargs).filter(wei=self.club, membership=None).distinct()
|
||||||
|
# Annotate the query to be able to sort registrations on validate status
|
||||||
|
qs = qs.annotate(
|
||||||
|
deposit=Case(
|
||||||
|
When(deposit_type='note', then=F('wei__deposit_amount')),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
).annotate(
|
||||||
|
total_fee=F('fee') + F('deposit')
|
||||||
|
).annotate(
|
||||||
|
validate_status=Case(
|
||||||
|
When(total_fee__gt=F('user__note__balance'), then=Value(2)),
|
||||||
|
When(first_year=True, then=Value(1)),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
pattern = self.request.GET.get("search", "")
|
pattern = self.request.GET.get("search", "")
|
||||||
|
|
||||||
@@ -510,7 +547,7 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
Register a new user to the WEI
|
Register a new user to the WEI
|
||||||
"""
|
"""
|
||||||
model = WEIRegistration
|
model = WEIRegistration
|
||||||
form_class = WEIRegistration1AForm
|
form_class = WEIRegistrationForm
|
||||||
extra_context = {"title": _("Register first year student to the WEI")}
|
extra_context = {"title": _("Register first year student to the WEI")}
|
||||||
|
|
||||||
def get_sample_object(self):
|
def get_sample_object(self):
|
||||||
@@ -560,13 +597,15 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
# Cacher les champs pendant l'inscription initiale
|
# Cacher les champs pendant l'inscription initiale
|
||||||
if "first_year" in form.fields:
|
if "first_year" in form.fields:
|
||||||
del form.fields["first_year"]
|
del form.fields["first_year"]
|
||||||
if "caution_check" in form.fields:
|
if "deposit_given" in form.fields:
|
||||||
del form.fields["caution_check"]
|
del form.fields["deposit_given"]
|
||||||
if "information_json" in form.fields:
|
if "information_json" in form.fields:
|
||||||
del form.fields["information_json"]
|
del form.fields["information_json"]
|
||||||
if "caution_type" in form.fields:
|
if "deposit_type" in form.fields:
|
||||||
del form.fields["caution_type"]
|
del form.fields["deposit_type"]
|
||||||
|
|
||||||
|
if "soge_credit" in form.fields:
|
||||||
|
form.fields["soge_credit"].help_text = _('Check if you will open a Société Générale account')
|
||||||
return form
|
return form
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -604,7 +643,7 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
Register an old user to the WEI
|
Register an old user to the WEI
|
||||||
"""
|
"""
|
||||||
model = WEIRegistration
|
model = WEIRegistration
|
||||||
form_class = WEIRegistration2AForm
|
form_class = WEIRegistrationForm
|
||||||
extra_context = {"title": _("Register old student to the WEI")}
|
extra_context = {"title": _("Register old student to the WEI")}
|
||||||
|
|
||||||
def get_sample_object(self):
|
def get_sample_object(self):
|
||||||
@@ -658,6 +697,9 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
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)
|
||||||
form.fields["user"].initial = self.request.user
|
form.fields["user"].initial = self.request.user
|
||||||
|
if "soge_credit" in form.fields:
|
||||||
|
form.fields["soge_credit"].help_text = _('Check if you will open a Société Générale account')
|
||||||
|
|
||||||
if "myself" in self.request.path and self.request.user.profile.soge:
|
if "myself" in self.request.path and self.request.user.profile.soge:
|
||||||
form.fields["soge_credit"].disabled = True
|
form.fields["soge_credit"].disabled = True
|
||||||
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
||||||
@@ -665,16 +707,16 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
# Cacher les champs pendant l'inscription initiale
|
# Cacher les champs pendant l'inscription initiale
|
||||||
if "first_year" in form.fields:
|
if "first_year" in form.fields:
|
||||||
del form.fields["first_year"]
|
del form.fields["first_year"]
|
||||||
if "caution_check" in form.fields:
|
if "deposit_given" in form.fields:
|
||||||
del form.fields["caution_check"]
|
del form.fields["deposit_given"]
|
||||||
if "information_json" in form.fields:
|
if "information_json" in form.fields:
|
||||||
del form.fields["information_json"]
|
del form.fields["information_json"]
|
||||||
|
|
||||||
# S'assurer que le champ caution_type est obligatoire
|
# S'assurer que le champ deposit_type est obligatoire
|
||||||
if "caution_type" in form.fields:
|
if "deposit_type" in form.fields:
|
||||||
form.fields["caution_type"].required = True
|
form.fields["deposit_type"].required = True
|
||||||
form.fields["caution_type"].help_text = _("Choose how you want to pay the deposit")
|
form.fields["deposit_type"].help_text = _("Choose how you want to pay the deposit")
|
||||||
form.fields["caution_type"].widget = forms.RadioSelect(choices=form.fields["caution_type"].choices)
|
form.fields["deposit_type"].widget = forms.RadioSelect(choices=form.fields["deposit_type"].choices)
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
@@ -703,7 +745,7 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
form.instance.information = information
|
form.instance.information = information
|
||||||
|
|
||||||
# Sauvegarder le type de caution
|
# Sauvegarder le type de caution
|
||||||
form.instance.caution_type = form.cleaned_data["caution_type"]
|
form.instance.deposit_type = form.cleaned_data["deposit_type"]
|
||||||
form.instance.save()
|
form.instance.save()
|
||||||
|
|
||||||
if 'treasury' in settings.INSTALLED_APPS:
|
if 'treasury' in settings.INSTALLED_APPS:
|
||||||
@@ -734,14 +776,11 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
|||||||
if today >= wei.date_start or today < wei.membership_start:
|
if today >= wei.date_start or today < wei.membership_start:
|
||||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||||
# Store the validate parameter in the view's state
|
# Store the validate parameter in the view's state
|
||||||
self.should_validate = request.GET.get('validate', False)
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
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["club"] = self.object.wei
|
context["club"] = self.object.wei
|
||||||
# Pass the validate parameter to the template
|
|
||||||
context["should_validate"] = self.should_validate
|
|
||||||
|
|
||||||
if self.object.is_validated:
|
if self.object.is_validated:
|
||||||
membership_form = self.get_membership_form(instance=self.object.membership,
|
membership_form = self.get_membership_form(instance=self.object.membership,
|
||||||
@@ -762,33 +801,37 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
|||||||
choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"])
|
choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"])
|
||||||
context["membership_form"] = choose_bus_form
|
context["membership_form"] = choose_bus_form
|
||||||
|
|
||||||
if not self.object.soge_credit and self.object.user.profile.soge:
|
|
||||||
form = context["form"]
|
|
||||||
form.fields["soge_credit"].disabled = True
|
|
||||||
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
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)
|
||||||
form.fields["user"].disabled = True
|
form.fields["user"].disabled = True
|
||||||
# The auto-json-format may cause issues with the default field remove
|
# The auto-json-format may cause issues with the default field remove
|
||||||
if not PermissionBackend.check_perm(self.request, 'wei.change_weiregistration_information_json', self.object):
|
if "information_json" in form.fields:
|
||||||
del form.fields["information_json"]
|
del form.fields["information_json"]
|
||||||
# Masquer le champ caution_check pour tout le monde dans le formulaire de modification
|
# Masquer le champ deposit_given pour tout le monde dans le formulaire de modification
|
||||||
if "caution_check" in form.fields:
|
if "deposit_given" in form.fields:
|
||||||
del form.fields["caution_check"]
|
form.fields["deposit_given"].help_text = _("Tick if the deposit check has been given")
|
||||||
|
if self.object.first_year or self.object.deposit_type == 'note':
|
||||||
|
del form.fields["deposit_given"]
|
||||||
|
|
||||||
# S'assurer que le champ caution_type est obligatoire pour les 2A+
|
# S'assurer que le champ deposit_type est obligatoire pour les 2A+
|
||||||
if not self.object.first_year and "caution_type" in form.fields:
|
if "deposit_type" in form.fields:
|
||||||
form.fields["caution_type"].required = True
|
if self.object.first_year:
|
||||||
form.fields["caution_type"].help_text = _("Choose how you want to pay the deposit")
|
del form.fields["deposit_type"]
|
||||||
form.fields["caution_type"].widget = forms.RadioSelect(choices=form.fields["caution_type"].choices)
|
else:
|
||||||
|
form.fields["deposit_type"].required = True
|
||||||
|
form.fields["deposit_type"].help_text = _("Choose how you want to pay the deposit")
|
||||||
|
|
||||||
|
if self.object.user.profile.soge:
|
||||||
|
form.fields["soge_credit"].disabled = True
|
||||||
|
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
def get_membership_form(self, data=None, instance=None):
|
def get_membership_form(self, data=None, instance=None):
|
||||||
membership_form = WEIMembershipForm(data if data else None, instance=instance)
|
registration = self.get_object()
|
||||||
|
membership_form = WEIMembershipForm(data if data else None, instance=instance, wei=registration.wei)
|
||||||
del membership_form.fields["credit_type"]
|
del membership_form.fields["credit_type"]
|
||||||
del membership_form.fields["credit_amount"]
|
del membership_form.fields["credit_amount"]
|
||||||
del membership_form.fields["first_name"]
|
del membership_form.fields["first_name"]
|
||||||
@@ -843,9 +886,8 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
|||||||
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
||||||
form.instance.information = information
|
form.instance.information = information
|
||||||
|
|
||||||
# Sauvegarder le type de caution pour les 2A+
|
if "deposit_type" in form.cleaned_data:
|
||||||
if "caution_type" in form.cleaned_data:
|
form.instance.deposit_type = form.cleaned_data["deposit_type"]
|
||||||
form.instance.caution_type = form.cleaned_data["caution_type"]
|
|
||||||
form.instance.save()
|
form.instance.save()
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
@@ -856,9 +898,6 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
|||||||
survey = CurrentSurvey(self.object)
|
survey = CurrentSurvey(self.object)
|
||||||
if not survey.is_complete():
|
if not survey.is_complete():
|
||||||
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
|
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
|
||||||
# On redirige vers la validation uniquement si c'est explicitement demandé (et stocké dans la vue)
|
|
||||||
if self.should_validate and self.request.user.has_perm("wei.add_weimembership"):
|
|
||||||
return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk})
|
|
||||||
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.pk})
|
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.pk})
|
||||||
|
|
||||||
|
|
||||||
@@ -951,15 +990,15 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
|
|
||||||
# Calculer le montant total nécessaire (frais + caution si transaction)
|
# Calculer le montant total nécessaire (frais + caution si transaction)
|
||||||
total_needed = fee
|
total_needed = fee
|
||||||
if registration.caution_type == 'note':
|
if registration.deposit_type == 'note':
|
||||||
total_needed += registration.wei.caution_amount
|
total_needed += registration.wei.deposit_amount
|
||||||
context["total_needed"] = total_needed
|
context["total_needed"] = total_needed
|
||||||
|
|
||||||
form = context["form"]
|
form = context["form"]
|
||||||
if registration.soge_credit:
|
if registration.soge_credit:
|
||||||
form.fields["credit_amount"].initial = registration.fee
|
form.fields["credit_amount"].initial = fee
|
||||||
else:
|
else:
|
||||||
form.fields["credit_amount"].initial = max(0, registration.fee - registration.user.note.balance)
|
form.fields["credit_amount"].initial = max(0, fee - registration.user.note.balance)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@@ -969,40 +1008,39 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
return WEIMembership1AForm
|
return WEIMembership1AForm
|
||||||
return WEIMembershipForm
|
return WEIMembershipForm
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||||
|
wei = registration.wei
|
||||||
|
kwargs['wei'] = wei
|
||||||
|
return kwargs
|
||||||
|
|
||||||
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)
|
||||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||||
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
|
||||||
|
|
||||||
# Ajouter le champ caution_check uniquement pour les non-première année et le rendre obligatoire
|
# Ajouter le champ deposit_given uniquement pour les non-première année et le rendre obligatoire
|
||||||
if not registration.first_year:
|
if not registration.first_year:
|
||||||
if registration.caution_type == 'check':
|
if registration.deposit_type == 'check':
|
||||||
form.fields["caution_check"] = forms.BooleanField(
|
form.fields["deposit_given"] = forms.BooleanField(
|
||||||
required=True,
|
required=True,
|
||||||
initial=registration.caution_check,
|
disabled=True,
|
||||||
label=_("Caution check given"),
|
initial=registration.deposit_given,
|
||||||
help_text=_("Please make sure the check is given before validating the registration")
|
label=_("Deposit check given"),
|
||||||
|
help_text=_("Only treasurers can validate this field")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
form.fields["caution_check"] = forms.BooleanField(
|
form.fields["deposit_given"] = forms.BooleanField(
|
||||||
required=True,
|
required=True,
|
||||||
initial=False,
|
initial=False,
|
||||||
label=_("Create deposit transaction"),
|
label=_("Create deposit transaction"),
|
||||||
help_text=_("A transaction of %(amount).2f€ will be created from the user's Note account") % {
|
help_text=_("A transaction of %(amount).2f€ will be created from the user's Note account") % {
|
||||||
'amount': registration.wei.caution_amount / 100
|
'amount': registration.wei.deposit_amount / 100
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if registration.soge_credit:
|
|
||||||
form.fields["credit_type"].disabled = True
|
|
||||||
form.fields["credit_type"].initial = NoteSpecial.objects.get(special_type="Virement bancaire")
|
|
||||||
form.fields["credit_amount"].disabled = True
|
|
||||||
form.fields["last_name"].disabled = True
|
|
||||||
form.fields["first_name"].disabled = True
|
|
||||||
form.fields["bank"].disabled = True
|
|
||||||
form.fields["bank"].initial = "Société générale"
|
|
||||||
|
|
||||||
if 'bus' in form.fields:
|
if 'bus' in form.fields:
|
||||||
# For 2A+ and hardcoded 1A
|
# For 2A+ and hardcoded 1A
|
||||||
form.fields["bus"].widget.attrs["api_url"] = "/api/wei/bus/?wei=" + str(registration.wei.pk)
|
form.fields["bus"].widget.attrs["api_url"] = "/api/wei/bus/?wei=" + str(registration.wei.pk)
|
||||||
@@ -1035,8 +1073,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
club = registration.wei
|
club = registration.wei
|
||||||
user = registration.user
|
user = registration.user
|
||||||
|
|
||||||
if "caution_check" in form.data:
|
if "deposit_given" in form.data:
|
||||||
registration.caution_check = form.data["caution_check"] == "on"
|
registration.deposit_given = form.data["deposit_given"] == "on"
|
||||||
registration.save()
|
registration.save()
|
||||||
membership = form.instance
|
membership = form.instance
|
||||||
membership.user = user
|
membership.user = user
|
||||||
@@ -1047,6 +1085,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
membership._force_renew_parent = True
|
membership._force_renew_parent = True
|
||||||
|
|
||||||
fee = club.membership_fee_paid if user.profile.paid else club.membership_fee_unpaid
|
fee = club.membership_fee_paid if user.profile.paid else club.membership_fee_unpaid
|
||||||
|
if registration.soge_credit:
|
||||||
|
fee = registration.wei.fee_soge_credit
|
||||||
|
|
||||||
kfet = club.parent_club
|
kfet = club.parent_club
|
||||||
bde = kfet.parent_club
|
bde = kfet.parent_club
|
||||||
@@ -1073,16 +1113,16 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
first_name = form.cleaned_data["first_name"]
|
first_name = form.cleaned_data["first_name"]
|
||||||
bank = form.cleaned_data["bank"]
|
bank = form.cleaned_data["bank"]
|
||||||
|
|
||||||
if credit_type is None or registration.soge_credit:
|
if credit_type is None:
|
||||||
credit_amount = 0
|
credit_amount = 0
|
||||||
|
|
||||||
# Calculer le montant total nécessaire (frais + caution si transaction)
|
# Calculer le montant total nécessaire (frais + caution si transaction)
|
||||||
total_needed = fee
|
total_needed = fee
|
||||||
if registration.caution_type == 'note':
|
if registration.deposit_type == 'note':
|
||||||
total_needed += club.caution_amount
|
total_needed += club.deposit_amount
|
||||||
|
|
||||||
# Vérifier que l'utilisateur a assez d'argent pour tout payer
|
# Vérifier que l'utilisateur a assez d'argent pour tout payer
|
||||||
if not registration.soge_credit and user.note.balance + credit_amount < total_needed:
|
if user.note.balance + credit_amount < total_needed:
|
||||||
form.add_error('credit_type',
|
form.add_error('credit_type',
|
||||||
_("This user doesn't have enough money to join this club and pay the deposit. "
|
_("This user doesn't have enough money to join this club and pay the deposit. "
|
||||||
"Current balance: %(balance)d€, credit: %(credit)d€, needed: %(needed)d€") % {
|
"Current balance: %(balance)d€, credit: %(credit)d€, needed: %(needed)d€") % {
|
||||||
@@ -1090,16 +1130,16 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
'credit': credit_amount,
|
'credit': credit_amount,
|
||||||
'needed': total_needed}
|
'needed': total_needed}
|
||||||
)
|
)
|
||||||
return super().form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
if credit_amount:
|
if credit_amount:
|
||||||
if not last_name:
|
if not last_name:
|
||||||
form.add_error('last_name', _("This field is required."))
|
form.add_error('last_name', _("This field is required."))
|
||||||
return super().form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
if not first_name:
|
if not first_name:
|
||||||
form.add_error('first_name', _("This field is required."))
|
form.add_error('first_name', _("This field is required."))
|
||||||
return super().form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
# Credit note before adding the membership
|
# Credit note before adding the membership
|
||||||
SpecialTransaction.objects.create(
|
SpecialTransaction.objects.create(
|
||||||
@@ -1130,24 +1170,73 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
|
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
|
||||||
|
|
||||||
# Créer la transaction de caution si nécessaire
|
# Créer la transaction de caution si nécessaire
|
||||||
if registration.caution_type == 'note':
|
if registration.deposit_type == 'note':
|
||||||
from note.models import Transaction
|
from note.models import Transaction
|
||||||
Transaction.objects.create(
|
Transaction.objects.create(
|
||||||
source=user.note,
|
source=user.note,
|
||||||
destination=club.note,
|
destination=club.note,
|
||||||
quantity=1,
|
quantity=1,
|
||||||
amount=club.caution_amount,
|
amount=club.deposit_amount,
|
||||||
reason=_("Caution %(name)s") % {'name': club.name},
|
reason=_("Deposit %(name)s") % {'name': club.name},
|
||||||
valid=True,
|
valid=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
registration = getattr(form.instance, "registration", None)
|
||||||
|
if registration is not None:
|
||||||
|
registration.deposit_given = False
|
||||||
|
registration.save()
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
self.object.refresh_from_db()
|
self.object.refresh_from_db()
|
||||||
return reverse_lazy("wei:wei_registrations", kwargs={"pk": self.object.club.pk})
|
return reverse_lazy("wei:wei_registrations", kwargs={"pk": self.object.club.pk})
|
||||||
|
|
||||||
|
|
||||||
|
class WEIUpdateMembershipView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Update a membership for the WEI
|
||||||
|
"""
|
||||||
|
model = WEIMembership
|
||||||
|
context_object_name = "membership"
|
||||||
|
template_name = "wei/weimembership_update.html"
|
||||||
|
extra_context = {"title": _("Update WEI Membership")}
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
wei = self.get_object().registration.wei
|
||||||
|
today = date.today()
|
||||||
|
# We can't update a registration once the WEI is started and before the membership start date
|
||||||
|
if today >= wei.date_start or today < wei.membership_start:
|
||||||
|
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||||
|
# Store the validate parameter in the view's state
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_form(self):
|
||||||
|
form = WEIMembershipForm(
|
||||||
|
self.request.POST or None,
|
||||||
|
self.request.FILES or None,
|
||||||
|
instance=self.object,
|
||||||
|
wei=self.object.registration.wei,
|
||||||
|
)
|
||||||
|
|
||||||
|
form.fields["roles"].initial = self.object.roles.all()
|
||||||
|
form.fields["bus"].initial = self.object.bus
|
||||||
|
form.fields["team"].initial = self.object.team
|
||||||
|
|
||||||
|
del form.fields["credit_type"]
|
||||||
|
del form.fields["credit_amount"]
|
||||||
|
del form.fields["first_name"]
|
||||||
|
del form.fields["last_name"]
|
||||||
|
del form.fields["bank"]
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.registration.wei.pk})
|
||||||
|
|
||||||
|
|
||||||
class WEISurveyView(LoginRequiredMixin, BaseFormView, DetailView):
|
class WEISurveyView(LoginRequiredMixin, BaseFormView, DetailView):
|
||||||
"""
|
"""
|
||||||
Display the survey for the WEI for first year members.
|
Display the survey for the WEI for first year members.
|
||||||
@@ -1170,6 +1259,10 @@ class WEISurveyView(LoginRequiredMixin, BaseFormView, DetailView):
|
|||||||
|
|
||||||
if not self.survey:
|
if not self.survey:
|
||||||
self.survey = CurrentSurvey(obj)
|
self.survey = CurrentSurvey(obj)
|
||||||
|
|
||||||
|
if request.GET.get("reset") == "true":
|
||||||
|
info = self.survey.information
|
||||||
|
info.reset(obj)
|
||||||
# If the survey is complete, then display the end page.
|
# If the survey is complete, then display the end page.
|
||||||
if self.survey.is_complete():
|
if self.survey.is_complete():
|
||||||
return redirect(reverse_lazy('wei:wei_survey_end', args=(self.survey.registration.pk,)))
|
return redirect(reverse_lazy('wei:wei_survey_end', args=(self.survey.registration.pk,)))
|
||||||
@@ -1422,3 +1515,29 @@ class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
|
|||||||
|
|
||||||
# On redirige vers la page d'attribution pour le premier étudiant trouvé
|
# On redirige vers la page d'attribution pour le premier étudiant trouvé
|
||||||
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk,))
|
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk,))
|
||||||
|
|
||||||
|
|
||||||
|
class BusInformationUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
model = Bus
|
||||||
|
|
||||||
|
def get_form_class(self):
|
||||||
|
return CurrentSurvey.get_algorithm_class().get_bus_information_form()
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
wei = self.get_object().wei
|
||||||
|
today = date.today()
|
||||||
|
# We can't update a bus once the WEI is started
|
||||||
|
if today >= wei.date_start:
|
||||||
|
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["club"] = self.object.wei
|
||||||
|
context["information"] = CurrentSurvey.get_algorithm_class().get_bus_information(self.object)
|
||||||
|
self.object.save()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse_lazy("wei:manage_bus", kwargs={"pk": self.object.pk})
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: \n"
|
"Project-Id-Version: \n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-06-20 14:02+0200\n"
|
"POT-Creation-Date: 2025-07-15 18:18+0200\n"
|
||||||
"PO-Revision-Date: 2022-04-11 23:12+0200\n"
|
"PO-Revision-Date: 2022-04-11 23:12+0200\n"
|
||||||
"Last-Translator: bleizi <bleizi@crans.org>\n"
|
"Last-Translator: bleizi <bleizi@crans.org>\n"
|
||||||
"Language-Team: \n"
|
"Language-Team: \n"
|
||||||
@@ -65,7 +65,7 @@ msgstr "Usted no puede invitar más de 3 persona a esta actividad."
|
|||||||
#: apps/note/models/transactions.py:46 apps/note/models/transactions.py:299
|
#: apps/note/models/transactions.py:46 apps/note/models/transactions.py:299
|
||||||
#: apps/permission/models.py:329
|
#: apps/permission/models.py:329
|
||||||
#: apps/registration/templates/registration/future_profile_detail.html:16
|
#: apps/registration/templates/registration/future_profile_detail.html:16
|
||||||
#: apps/wei/models.py:72 apps/wei/models.py:145 apps/wei/tables.py:282
|
#: apps/wei/models.py:77 apps/wei/models.py:150 apps/wei/tables.py:282
|
||||||
#: apps/wei/templates/wei/base.html:26
|
#: apps/wei/templates/wei/base.html:26
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:14 apps/wrapped/models.py:16
|
#: apps/wei/templates/wei/weimembership_form.html:14 apps/wrapped/models.py:16
|
||||||
msgid "name"
|
msgid "name"
|
||||||
@@ -100,7 +100,7 @@ msgstr "tipos de actividad"
|
|||||||
#: apps/activity/models.py:68
|
#: apps/activity/models.py:68
|
||||||
#: apps/activity/templates/activity/includes/activity_info.html:19
|
#: apps/activity/templates/activity/includes/activity_info.html:19
|
||||||
#: apps/note/models/transactions.py:82 apps/permission/models.py:109
|
#: apps/note/models/transactions.py:82 apps/permission/models.py:109
|
||||||
#: apps/permission/models.py:188 apps/wei/models.py:92 apps/wei/models.py:156
|
#: apps/permission/models.py:188 apps/wei/models.py:97 apps/wei/models.py:161
|
||||||
msgid "description"
|
msgid "description"
|
||||||
msgstr "descripción"
|
msgstr "descripción"
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ msgstr "tipo"
|
|||||||
|
|
||||||
#: apps/activity/models.py:91 apps/logs/models.py:22 apps/member/models.py:325
|
#: apps/activity/models.py:91 apps/logs/models.py:22 apps/member/models.py:325
|
||||||
#: apps/note/models/notes.py:148 apps/treasury/models.py:294
|
#: apps/note/models/notes.py:148 apps/treasury/models.py:294
|
||||||
#: apps/wei/models.py:185 apps/wei/templates/wei/attribute_bus_1A.html:13
|
#: apps/wei/models.py:190 apps/wei/templates/wei/attribute_bus_1A.html:13
|
||||||
#: apps/wei/templates/wei/survey.html:15
|
#: apps/wei/templates/wei/survey.html:15
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "usuario"
|
msgstr "usuario"
|
||||||
@@ -1297,7 +1297,7 @@ msgid "add to registration form"
|
|||||||
msgstr "Validar la afiliación"
|
msgstr "Validar la afiliación"
|
||||||
|
|
||||||
#: apps/member/models.py:268 apps/member/models.py:331
|
#: apps/member/models.py:268 apps/member/models.py:331
|
||||||
#: apps/note/models/notes.py:176 apps/wei/models.py:86
|
#: apps/note/models/notes.py:176 apps/wei/models.py:91
|
||||||
msgid "club"
|
msgid "club"
|
||||||
msgstr "club"
|
msgstr "club"
|
||||||
|
|
||||||
@@ -2017,8 +2017,8 @@ msgstr ""
|
|||||||
"pago y un usuario o un club"
|
"pago y un usuario o un club"
|
||||||
|
|
||||||
#: apps/note/models/transactions.py:357 apps/note/models/transactions.py:360
|
#: apps/note/models/transactions.py:357 apps/note/models/transactions.py:360
|
||||||
#: apps/note/models/transactions.py:363 apps/wei/views.py:1097
|
#: apps/note/models/transactions.py:363 apps/wei/views.py:1103
|
||||||
#: apps/wei/views.py:1101
|
#: apps/wei/views.py:1107
|
||||||
msgid "This field is required."
|
msgid "This field is required."
|
||||||
msgstr "Este campo es obligatorio."
|
msgstr "Este campo es obligatorio."
|
||||||
|
|
||||||
@@ -2515,7 +2515,7 @@ msgstr "El usuario declara que ya abrió una cuenta a la Société Générale."
|
|||||||
|
|
||||||
#: apps/registration/templates/registration/future_profile_detail.html:73
|
#: apps/registration/templates/registration/future_profile_detail.html:73
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:127
|
#: apps/wei/templates/wei/weimembership_form.html:127
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:196
|
#: apps/wei/templates/wei/weimembership_form.html:192
|
||||||
msgid "Validate registration"
|
msgid "Validate registration"
|
||||||
msgstr "Validar la afiliación"
|
msgstr "Validar la afiliación"
|
||||||
|
|
||||||
@@ -3043,8 +3043,8 @@ msgstr "Lista de los créditos de la Société Générale"
|
|||||||
msgid "Manage credits from the Société générale"
|
msgid "Manage credits from the Société générale"
|
||||||
msgstr "Gestionar los créditos de la Société Générale"
|
msgstr "Gestionar los créditos de la Société Générale"
|
||||||
|
|
||||||
#: apps/wei/apps.py:10 apps/wei/models.py:42 apps/wei/models.py:43
|
#: apps/wei/apps.py:10 apps/wei/models.py:47 apps/wei/models.py:48
|
||||||
#: apps/wei/models.py:67 apps/wei/models.py:192
|
#: apps/wei/models.py:72 apps/wei/models.py:197
|
||||||
#: note_kfet/templates/base.html:108
|
#: note_kfet/templates/base.html:108
|
||||||
msgid "WEI"
|
msgid "WEI"
|
||||||
msgstr "WEI"
|
msgstr "WEI"
|
||||||
@@ -3054,8 +3054,8 @@ msgid "The selected user is not validated. Please validate its account first"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"El usuario seleccionado no ha sido validado. Validar esta cuenta primero"
|
"El usuario seleccionado no ha sido validado. Validar esta cuenta primero"
|
||||||
|
|
||||||
#: apps/wei/forms/registration.py:84 apps/wei/models.py:140
|
#: apps/wei/forms/registration.py:84 apps/wei/models.py:145
|
||||||
#: apps/wei/models.py:348
|
#: apps/wei/models.py:354
|
||||||
msgid "bus"
|
msgid "bus"
|
||||||
msgstr "bus"
|
msgstr "bus"
|
||||||
|
|
||||||
@@ -3081,7 +3081,7 @@ msgstr ""
|
|||||||
"electrón libre)"
|
"electrón libre)"
|
||||||
|
|
||||||
#: apps/wei/forms/registration.py:100 apps/wei/forms/registration.py:110
|
#: apps/wei/forms/registration.py:100 apps/wei/forms/registration.py:110
|
||||||
#: apps/wei/models.py:174
|
#: apps/wei/models.py:179
|
||||||
msgid "WEI Roles"
|
msgid "WEI Roles"
|
||||||
msgstr "Papeles en el WEI"
|
msgstr "Papeles en el WEI"
|
||||||
|
|
||||||
@@ -3089,14 +3089,19 @@ msgstr "Papeles en el WEI"
|
|||||||
msgid "Select the roles that you are interested in."
|
msgid "Select the roles that you are interested in."
|
||||||
msgstr "Elegir los papeles que le interesa."
|
msgstr "Elegir los papeles que le interesa."
|
||||||
|
|
||||||
#: apps/wei/forms/registration.py:147
|
#: apps/wei/forms/registration.py:160
|
||||||
msgid "This team doesn't belong to the given bus."
|
msgid "This team doesn't belong to the given bus."
|
||||||
msgstr "Este equipo no pertenece al bus dado."
|
msgstr "Este equipo no pertenece al bus dado."
|
||||||
|
|
||||||
#: apps/wei/forms/surveys/wei2021.py:35 apps/wei/forms/surveys/wei2022.py:38
|
#: apps/wei/forms/surveys/wei2021.py:35 apps/wei/forms/surveys/wei2022.py:38
|
||||||
|
#: apps/wei/forms/surveys/wei2025.py:36
|
||||||
msgid "Choose a word:"
|
msgid "Choose a word:"
|
||||||
msgstr "Elegir una palabra :"
|
msgstr "Elegir una palabra :"
|
||||||
|
|
||||||
|
#: apps/wei/forms/surveys/wei2025.py:123
|
||||||
|
msgid "Rate between 0 and 5."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: apps/wei/models.py:25 apps/wei/templates/wei/base.html:36
|
#: apps/wei/models.py:25 apps/wei/templates/wei/base.html:36
|
||||||
msgid "year"
|
msgid "year"
|
||||||
msgstr "año"
|
msgstr "año"
|
||||||
@@ -3113,138 +3118,147 @@ msgstr "fecha de fin"
|
|||||||
|
|
||||||
#: apps/wei/models.py:37
|
#: apps/wei/models.py:37
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "total amount"
|
#| msgid "Credit amount"
|
||||||
msgid "caution amount"
|
msgid "deposit amount"
|
||||||
msgstr "monto total"
|
msgstr "Valor del crédito"
|
||||||
|
|
||||||
#: apps/wei/models.py:76 apps/wei/tables.py:305
|
#: apps/wei/models.py:42
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "No credit"
|
||||||
|
msgid "membership fee (soge credit)"
|
||||||
|
msgstr "No crédito"
|
||||||
|
|
||||||
|
#: apps/wei/models.py:81 apps/wei/tables.py:305
|
||||||
msgid "seat count in the bus"
|
msgid "seat count in the bus"
|
||||||
msgstr "cantidad de asientos en el bus"
|
msgstr "cantidad de asientos en el bus"
|
||||||
|
|
||||||
#: apps/wei/models.py:97
|
#: apps/wei/models.py:102
|
||||||
msgid "survey information"
|
msgid "survey information"
|
||||||
msgstr "informaciones sobre el cuestionario"
|
msgstr "informaciones sobre el cuestionario"
|
||||||
|
|
||||||
#: apps/wei/models.py:98
|
#: apps/wei/models.py:103
|
||||||
msgid "Information about the survey for new members, encoded in JSON"
|
msgid "Information about the survey for new members, encoded in JSON"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Informaciones sobre el cuestionario para los nuevos miembros, registrado en "
|
"Informaciones sobre el cuestionario para los nuevos miembros, registrado en "
|
||||||
"JSON"
|
"JSON"
|
||||||
|
|
||||||
#: apps/wei/models.py:102
|
#: apps/wei/models.py:107
|
||||||
msgid "Bus"
|
msgid "Bus"
|
||||||
msgstr "Bus"
|
msgstr "Bus"
|
||||||
|
|
||||||
#: apps/wei/models.py:103 apps/wei/templates/wei/weiclub_detail.html:51
|
#: apps/wei/models.py:108 apps/wei/templates/wei/weiclub_detail.html:51
|
||||||
msgid "Buses"
|
msgid "Buses"
|
||||||
msgstr "Bus"
|
msgstr "Bus"
|
||||||
|
|
||||||
#: apps/wei/models.py:149
|
#: apps/wei/models.py:154
|
||||||
msgid "color"
|
msgid "color"
|
||||||
msgstr "color"
|
msgstr "color"
|
||||||
|
|
||||||
#: apps/wei/models.py:150
|
#: apps/wei/models.py:155
|
||||||
msgid "The color of the T-Shirt, stored with its number equivalent"
|
msgid "The color of the T-Shirt, stored with its number equivalent"
|
||||||
msgstr "El color de la camiseta, registrado con su número equivalente"
|
msgstr "El color de la camiseta, registrado con su número equivalente"
|
||||||
|
|
||||||
#: apps/wei/models.py:161
|
#: apps/wei/models.py:166
|
||||||
msgid "Bus team"
|
msgid "Bus team"
|
||||||
msgstr "Equipo de bus"
|
msgstr "Equipo de bus"
|
||||||
|
|
||||||
#: apps/wei/models.py:162
|
#: apps/wei/models.py:167
|
||||||
msgid "Bus teams"
|
msgid "Bus teams"
|
||||||
msgstr "Equipos de bus"
|
msgstr "Equipos de bus"
|
||||||
|
|
||||||
#: apps/wei/models.py:173
|
#: apps/wei/models.py:178
|
||||||
msgid "WEI Role"
|
msgid "WEI Role"
|
||||||
msgstr "Papeles en el WEI"
|
msgstr "Papeles en el WEI"
|
||||||
|
|
||||||
#: apps/wei/models.py:197
|
#: apps/wei/models.py:202
|
||||||
msgid "Credit from Société générale"
|
msgid "Credit from Société générale"
|
||||||
msgstr "Crédito de la Société Générale"
|
msgstr "Crédito de la Société Générale"
|
||||||
|
|
||||||
#: apps/wei/models.py:202 apps/wei/views.py:984
|
#: apps/wei/models.py:207 apps/wei/templates/wei/weimembership_form.html:98
|
||||||
msgid "Caution check given"
|
#: apps/wei/views.py:997
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Caution check given"
|
||||||
|
msgid "Deposit check given"
|
||||||
msgstr "Cheque de garantía dado"
|
msgstr "Cheque de garantía dado"
|
||||||
|
|
||||||
#: apps/wei/models.py:208
|
#: apps/wei/models.py:213
|
||||||
msgid "Check"
|
msgid "Check"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/wei/models.py:209
|
#: apps/wei/models.py:214
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "transactions"
|
#| msgid "transactions"
|
||||||
msgid "Note transaction"
|
msgid "Note transaction"
|
||||||
msgstr "Transacción"
|
msgstr "Transacción"
|
||||||
|
|
||||||
#: apps/wei/models.py:212
|
#: apps/wei/models.py:217
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "created at"
|
#| msgid "Credit type"
|
||||||
msgid "caution type"
|
msgid "deposit type"
|
||||||
msgstr "tipo de fianza"
|
msgstr "Tipo de crédito"
|
||||||
|
|
||||||
#: apps/wei/models.py:216 apps/wei/templates/wei/weimembership_form.html:64
|
#: apps/wei/models.py:221 apps/wei/templates/wei/weimembership_form.html:64
|
||||||
msgid "birth date"
|
msgid "birth date"
|
||||||
msgstr "fecha de nacimiento"
|
msgstr "fecha de nacimiento"
|
||||||
|
|
||||||
#: apps/wei/models.py:222 apps/wei/models.py:232
|
#: apps/wei/models.py:227 apps/wei/models.py:237
|
||||||
msgid "Male"
|
msgid "Male"
|
||||||
msgstr "Hombre"
|
msgstr "Hombre"
|
||||||
|
|
||||||
#: apps/wei/models.py:223 apps/wei/models.py:233
|
#: apps/wei/models.py:228 apps/wei/models.py:238
|
||||||
msgid "Female"
|
msgid "Female"
|
||||||
msgstr "Mujer"
|
msgstr "Mujer"
|
||||||
|
|
||||||
#: apps/wei/models.py:224
|
#: apps/wei/models.py:229
|
||||||
msgid "Non binary"
|
msgid "Non binary"
|
||||||
msgstr "No binari@"
|
msgstr "No binari@"
|
||||||
|
|
||||||
#: apps/wei/models.py:226 apps/wei/templates/wei/attribute_bus_1A.html:22
|
#: apps/wei/models.py:231 apps/wei/templates/wei/attribute_bus_1A.html:22
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:55
|
#: apps/wei/templates/wei/weimembership_form.html:55
|
||||||
msgid "gender"
|
msgid "gender"
|
||||||
msgstr "género"
|
msgstr "género"
|
||||||
|
|
||||||
#: apps/wei/models.py:234
|
#: apps/wei/models.py:239
|
||||||
msgid "Unisex"
|
msgid "Unisex"
|
||||||
msgstr "Unisex"
|
msgstr "Unisex"
|
||||||
|
|
||||||
#: apps/wei/models.py:237 apps/wei/templates/wei/weimembership_form.html:58
|
#: apps/wei/models.py:242 apps/wei/templates/wei/weimembership_form.html:58
|
||||||
msgid "clothing cut"
|
msgid "clothing cut"
|
||||||
msgstr "forma de ropa"
|
msgstr "forma de ropa"
|
||||||
|
|
||||||
#: apps/wei/models.py:250 apps/wei/templates/wei/weimembership_form.html:61
|
#: apps/wei/models.py:255 apps/wei/templates/wei/weimembership_form.html:61
|
||||||
msgid "clothing size"
|
msgid "clothing size"
|
||||||
msgstr "medida de ropa"
|
msgstr "medida de ropa"
|
||||||
|
|
||||||
#: apps/wei/models.py:256
|
#: apps/wei/models.py:261
|
||||||
msgid "health issues"
|
msgid "health issues"
|
||||||
msgstr "problemas de salud"
|
msgstr "problemas de salud"
|
||||||
|
|
||||||
#: apps/wei/models.py:261 apps/wei/templates/wei/weimembership_form.html:70
|
#: apps/wei/models.py:266 apps/wei/templates/wei/weimembership_form.html:70
|
||||||
msgid "emergency contact name"
|
msgid "emergency contact name"
|
||||||
msgstr "nombre del contacto de emergencia"
|
msgstr "nombre del contacto de emergencia"
|
||||||
|
|
||||||
#: apps/wei/models.py:262
|
#: apps/wei/models.py:267
|
||||||
msgid "The emergency contact must not be a WEI participant"
|
msgid "The emergency contact must not be a WEI participant"
|
||||||
msgstr "El contacto de emergencia no debe ser un participante de WEI"
|
msgstr "El contacto de emergencia no debe ser un participante de WEI"
|
||||||
|
|
||||||
#: apps/wei/models.py:267 apps/wei/templates/wei/weimembership_form.html:73
|
#: apps/wei/models.py:272 apps/wei/templates/wei/weimembership_form.html:73
|
||||||
msgid "emergency contact phone"
|
msgid "emergency contact phone"
|
||||||
msgstr "teléfono del contacto de emergencia"
|
msgstr "teléfono del contacto de emergencia"
|
||||||
|
|
||||||
#: apps/wei/models.py:272 apps/wei/templates/wei/weimembership_form.html:52
|
#: apps/wei/models.py:277 apps/wei/templates/wei/weimembership_form.html:52
|
||||||
msgid "first year"
|
msgid "first year"
|
||||||
msgstr "primer año"
|
msgstr "primer año"
|
||||||
|
|
||||||
#: apps/wei/models.py:273
|
#: apps/wei/models.py:278
|
||||||
msgid "Tells if the user is new in the school."
|
msgid "Tells if the user is new in the school."
|
||||||
msgstr "Indica si el usuario es nuevo en la escuela."
|
msgstr "Indica si el usuario es nuevo en la escuela."
|
||||||
|
|
||||||
#: apps/wei/models.py:278
|
#: apps/wei/models.py:283
|
||||||
msgid "registration information"
|
msgid "registration information"
|
||||||
msgstr "informaciones sobre la afiliación"
|
msgstr "informaciones sobre la afiliación"
|
||||||
|
|
||||||
#: apps/wei/models.py:279
|
#: apps/wei/models.py:284
|
||||||
msgid ""
|
msgid ""
|
||||||
"Information about the registration (buses for old members, survey for the "
|
"Information about the registration (buses for old members, survey for the "
|
||||||
"new members), encoded in JSON"
|
"new members), encoded in JSON"
|
||||||
@@ -3252,27 +3266,27 @@ msgstr ""
|
|||||||
"Informaciones sobre la afiliacion (bus para miembros ancianos, cuestionario "
|
"Informaciones sobre la afiliacion (bus para miembros ancianos, cuestionario "
|
||||||
"para los nuevos miembros), registrado en JSON"
|
"para los nuevos miembros), registrado en JSON"
|
||||||
|
|
||||||
#: apps/wei/models.py:285
|
#: apps/wei/models.py:290
|
||||||
msgid "WEI User"
|
msgid "WEI User"
|
||||||
msgstr "Participante WEI"
|
msgstr "Participante WEI"
|
||||||
|
|
||||||
#: apps/wei/models.py:286
|
#: apps/wei/models.py:291
|
||||||
msgid "WEI Users"
|
msgid "WEI Users"
|
||||||
msgstr "Participantes WEI"
|
msgstr "Participantes WEI"
|
||||||
|
|
||||||
#: apps/wei/models.py:358
|
#: apps/wei/models.py:364
|
||||||
msgid "team"
|
msgid "team"
|
||||||
msgstr "equipo"
|
msgstr "equipo"
|
||||||
|
|
||||||
#: apps/wei/models.py:368
|
#: apps/wei/models.py:374
|
||||||
msgid "WEI registration"
|
msgid "WEI registration"
|
||||||
msgstr "Apuntación al WEI"
|
msgstr "Apuntación al WEI"
|
||||||
|
|
||||||
#: apps/wei/models.py:372
|
#: apps/wei/models.py:378
|
||||||
msgid "WEI membership"
|
msgid "WEI membership"
|
||||||
msgstr "Afiliación al WEI"
|
msgstr "Afiliación al WEI"
|
||||||
|
|
||||||
#: apps/wei/models.py:373
|
#: apps/wei/models.py:379
|
||||||
msgid "WEI memberships"
|
msgid "WEI memberships"
|
||||||
msgstr "Afiliaciones al WEI"
|
msgstr "Afiliaciones al WEI"
|
||||||
|
|
||||||
@@ -3300,7 +3314,7 @@ msgstr "Año"
|
|||||||
msgid "preferred bus"
|
msgid "preferred bus"
|
||||||
msgstr "bus preferido"
|
msgstr "bus preferido"
|
||||||
|
|
||||||
#: apps/wei/tables.py:210 apps/wei/templates/wei/bus_detail.html:36
|
#: apps/wei/tables.py:210 apps/wei/templates/wei/bus_detail.html:38
|
||||||
#: apps/wei/templates/wei/busteam_detail.html:52
|
#: apps/wei/templates/wei/busteam_detail.html:52
|
||||||
msgid "Teams"
|
msgid "Teams"
|
||||||
msgstr "Equipos"
|
msgstr "Equipos"
|
||||||
@@ -3372,9 +3386,9 @@ msgstr "Pago de entrada del WEI (estudiantes no pagados)"
|
|||||||
|
|
||||||
#: apps/wei/templates/wei/base.html:53
|
#: apps/wei/templates/wei/base.html:53
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "total amount"
|
#| msgid "Credit amount"
|
||||||
msgid "Caution amount"
|
msgid "Deposit amount"
|
||||||
msgstr "monto total"
|
msgstr "Valor del crédito"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/base.html:74
|
#: apps/wei/templates/wei/base.html:74
|
||||||
msgid "WEI list"
|
msgid "WEI list"
|
||||||
@@ -3384,7 +3398,7 @@ msgstr "Lista de los WEI"
|
|||||||
msgid "Register 1A"
|
msgid "Register 1A"
|
||||||
msgstr "Apuntar un 1A"
|
msgstr "Apuntar un 1A"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/base.html:83 apps/wei/views.py:644
|
#: apps/wei/templates/wei/base.html:83 apps/wei/views.py:646
|
||||||
msgid "Register 2A+"
|
msgid "Register 2A+"
|
||||||
msgstr "Apuntar un 2A+"
|
msgstr "Apuntar un 2A+"
|
||||||
|
|
||||||
@@ -3401,15 +3415,21 @@ msgid "View club"
|
|||||||
msgstr "Ver club"
|
msgstr "Ver club"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/bus_detail.html:26
|
#: apps/wei/templates/wei/bus_detail.html:26
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "survey information"
|
||||||
|
msgid "Edit information"
|
||||||
|
msgstr "informaciones sobre el cuestionario"
|
||||||
|
|
||||||
|
#: apps/wei/templates/wei/bus_detail.html:28
|
||||||
#: apps/wei/templates/wei/busteam_detail.html:24
|
#: apps/wei/templates/wei/busteam_detail.html:24
|
||||||
msgid "Add team"
|
msgid "Add team"
|
||||||
msgstr "Añadir un equipo"
|
msgstr "Añadir un equipo"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/bus_detail.html:49
|
#: apps/wei/templates/wei/bus_detail.html:51
|
||||||
msgid "Members"
|
msgid "Members"
|
||||||
msgstr "Miembros"
|
msgstr "Miembros"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/bus_detail.html:58
|
#: apps/wei/templates/wei/bus_detail.html:60
|
||||||
#: apps/wei/templates/wei/busteam_detail.html:62
|
#: apps/wei/templates/wei/busteam_detail.html:62
|
||||||
#: apps/wei/templates/wei/weimembership_list.html:31
|
#: apps/wei/templates/wei/weimembership_list.html:31
|
||||||
msgid "View as PDF"
|
msgid "View as PDF"
|
||||||
@@ -3417,8 +3437,8 @@ msgstr "Descargar un PDF"
|
|||||||
|
|
||||||
#: apps/wei/templates/wei/survey.html:11
|
#: apps/wei/templates/wei/survey.html:11
|
||||||
#: apps/wei/templates/wei/survey_closed.html:11
|
#: apps/wei/templates/wei/survey_closed.html:11
|
||||||
#: apps/wei/templates/wei/survey_end.html:11 apps/wei/views.py:1159
|
#: apps/wei/templates/wei/survey_end.html:11 apps/wei/views.py:1165
|
||||||
#: apps/wei/views.py:1214 apps/wei/views.py:1261
|
#: apps/wei/views.py:1220 apps/wei/views.py:1267
|
||||||
msgid "Survey WEI"
|
msgid "Survey WEI"
|
||||||
msgstr "Cuestionario WEI"
|
msgstr "Cuestionario WEI"
|
||||||
|
|
||||||
@@ -3494,10 +3514,6 @@ msgstr "Informaciones crudas del cuestionario"
|
|||||||
msgid "The algorithm didn't run."
|
msgid "The algorithm didn't run."
|
||||||
msgstr "El algoritmo no funcionó."
|
msgstr "El algoritmo no funcionó."
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:98
|
|
||||||
msgid "caution check given"
|
|
||||||
msgstr "cheque de garantía dado"
|
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:105
|
#: apps/wei/templates/wei/weimembership_form.html:105
|
||||||
msgid "preferred team"
|
msgid "preferred team"
|
||||||
msgstr "equipo preferido"
|
msgstr "equipo preferido"
|
||||||
@@ -3532,11 +3548,18 @@ msgid "with the following roles:"
|
|||||||
msgstr "con los papeles :"
|
msgstr "con los papeles :"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:139
|
#: apps/wei/templates/wei/weimembership_form.html:139
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid ""
|
||||||
|
#| "The WEI will be paid by Société générale. The membership will be created "
|
||||||
|
#| "even if the bank didn't pay the BDE yet. The membership transaction will "
|
||||||
|
#| "be created but will be invalid. You will have to validate it once the "
|
||||||
|
#| "bank validated the creation of the account, or to change the payment "
|
||||||
|
#| "method."
|
||||||
msgid ""
|
msgid ""
|
||||||
"The WEI will be paid by Société générale. The membership will be created "
|
"The WEI will partially be paid by Société générale. The membership will be "
|
||||||
"even if the bank didn't pay the BDE yet. The membership transaction will be "
|
"created even if the bank didn't pay the BDE yet. The membership transaction "
|
||||||
"created but will be invalid. You will have to validate it once the bank "
|
"will be created but will be invalid. You will have to validate it once the "
|
||||||
"validated the creation of the account, or to change the payment method."
|
"bank validated the creation of the account, or to change the payment method."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"El WEI será pagado por la Société Générale. La afiliación será creada aunque "
|
"El WEI será pagado por la Société Générale. La afiliación será creada aunque "
|
||||||
"el banco no pago el BDE ya. La transacción de afiliación será creada pero "
|
"el banco no pago el BDE ya. La transacción de afiliación será creada pero "
|
||||||
@@ -3558,27 +3581,26 @@ msgstr "Pagos de afiliación (estudiantes pagados)"
|
|||||||
msgid "Deposit (by Note transaction): %(amount)s"
|
msgid "Deposit (by Note transaction): %(amount)s"
|
||||||
msgstr "Fianza (transacción) : %(amount)s"
|
msgstr "Fianza (transacción) : %(amount)s"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:156
|
#: apps/wei/templates/wei/weimembership_form.html:157
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:163
|
|
||||||
#, python-format
|
|
||||||
msgid "Total needed: %(total)s"
|
|
||||||
msgstr "Total necesario : %(total)s"
|
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:160
|
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Deposit (by check): %(amount)s"
|
msgid "Deposit (by check): %(amount)s"
|
||||||
msgstr "Fianza (cheque) : %(amount)s"
|
msgstr "Fianza (cheque) : %(amount)s"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:168
|
#: apps/wei/templates/wei/weimembership_form.html:161
|
||||||
|
#, python-format
|
||||||
|
msgid "Total needed: %(total)s"
|
||||||
|
msgstr "Total necesario : %(total)s"
|
||||||
|
|
||||||
|
#: apps/wei/templates/wei/weimembership_form.html:165
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Current balance: %(balance)s"
|
msgid "Current balance: %(balance)s"
|
||||||
msgstr "Saldo actual : %(balance)s"
|
msgstr "Saldo actual : %(balance)s"
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:176
|
#: apps/wei/templates/wei/weimembership_form.html:172
|
||||||
msgid "The user didn't give her/his caution check."
|
msgid "The user didn't give her/his caution check."
|
||||||
msgstr "El usuario no dio su cheque de garantía."
|
msgstr "El usuario no dio su cheque de garantía."
|
||||||
|
|
||||||
#: apps/wei/templates/wei/weimembership_form.html:184
|
#: apps/wei/templates/wei/weimembership_form.html:180
|
||||||
msgid ""
|
msgid ""
|
||||||
"This user is not a member of the Kfet club for the coming year. The "
|
"This user is not a member of the Kfet club for the coming year. The "
|
||||||
"membership will be processed automatically, the WEI registration includes "
|
"membership will be processed automatically, the WEI registration includes "
|
||||||
@@ -3668,110 +3690,109 @@ msgstr "Gestionar el equipo"
|
|||||||
msgid "Register first year student to the WEI"
|
msgid "Register first year student to the WEI"
|
||||||
msgstr "Registrar un 1A al WEI"
|
msgstr "Registrar un 1A al WEI"
|
||||||
|
|
||||||
#: apps/wei/views.py:580 apps/wei/views.py:689
|
#: apps/wei/views.py:571 apps/wei/views.py:664
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Check this case if the Société Générale paid the inscription."
|
||||||
|
msgid "Check if you will open a Société Générale account"
|
||||||
|
msgstr "Marcar esta casilla si Société Générale pagó la registración."
|
||||||
|
|
||||||
|
#: apps/wei/views.py:582 apps/wei/views.py:694
|
||||||
msgid "This user is already registered to this WEI."
|
msgid "This user is already registered to this WEI."
|
||||||
msgstr "Este usuario ya afilió a este WEI."
|
msgstr "Este usuario ya afilió a este WEI."
|
||||||
|
|
||||||
#: apps/wei/views.py:585
|
#: apps/wei/views.py:587
|
||||||
msgid ""
|
msgid ""
|
||||||
"This user can't be in her/his first year since he/she has already "
|
"This user can't be in her/his first year since he/she has already "
|
||||||
"participated to a WEI."
|
"participated to a WEI."
|
||||||
msgstr "Este usuario no puede ser un 1A porque ya participó en un WEI."
|
msgstr "Este usuario no puede ser un 1A porque ya participó en un WEI."
|
||||||
|
|
||||||
#: apps/wei/views.py:608
|
#: apps/wei/views.py:610
|
||||||
msgid "Register old student to the WEI"
|
msgid "Register old student to the WEI"
|
||||||
msgstr "Registrar un 2A+ al WEI"
|
msgstr "Registrar un 2A+ al WEI"
|
||||||
|
|
||||||
#: apps/wei/views.py:663 apps/wei/views.py:768
|
#: apps/wei/views.py:668 apps/wei/views.py:773
|
||||||
msgid "You already opened an account in the Société générale."
|
msgid "You already opened an account in the Société générale."
|
||||||
msgstr "Usted ya abrió una cuenta a la Société Générale."
|
msgstr "Usted ya abrió una cuenta a la Société Générale."
|
||||||
|
|
||||||
#: apps/wei/views.py:676 apps/wei/views.py:785
|
#: apps/wei/views.py:681 apps/wei/views.py:790
|
||||||
msgid "Choose how you want to pay the deposit"
|
msgid "Choose how you want to pay the deposit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/wei/views.py:728
|
#: apps/wei/views.py:733
|
||||||
msgid "Update WEI Registration"
|
msgid "Update WEI Registration"
|
||||||
msgstr "Modificar la inscripción WEI"
|
msgstr "Modificar la inscripción WEI"
|
||||||
|
|
||||||
#: apps/wei/views.py:810
|
#: apps/wei/views.py:816
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "The BDE membership is included in the WEI registration."
|
#| msgid "The BDE membership is included in the WEI registration."
|
||||||
msgid "No membership found for this registration"
|
msgid "No membership found for this registration"
|
||||||
msgstr "La afiliación al BDE esta incluida en la afiliación WEI."
|
msgstr "La afiliación al BDE esta incluida en la afiliación WEI."
|
||||||
|
|
||||||
#: apps/wei/views.py:819
|
#: apps/wei/views.py:825
|
||||||
#| msgid ""
|
|
||||||
#| "You don't have the permission to add an instance of model {app_label}."
|
|
||||||
#| "{model_name}."
|
|
||||||
msgid "You don't have the permission to update memberships"
|
msgid "You don't have the permission to update memberships"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Usted no tiene permiso a añadir una instancia al modelo {app_label}."
|
"Usted no tiene permiso a añadir una instancia al modelo {app_label}."
|
||||||
"{model_name}."
|
"{model_name}."
|
||||||
|
|
||||||
#: apps/wei/views.py:825
|
#: apps/wei/views.py:831
|
||||||
#, python-format
|
#, python-format
|
||||||
#| msgid ""
|
|
||||||
#| "You don't have the permission to delete this instance of model "
|
|
||||||
#| "{app_label}.{model_name}."
|
|
||||||
msgid "You don't have the permission to update the field %(field)s"
|
msgid "You don't have the permission to update the field %(field)s"
|
||||||
msgstr "Usted no tiene permiso a modificar el campo %(field)s"
|
msgstr "Usted no tiene permiso a modificar el campo %(field)s"
|
||||||
|
|
||||||
#: apps/wei/views.py:870
|
#: apps/wei/views.py:876
|
||||||
msgid "Delete WEI registration"
|
msgid "Delete WEI registration"
|
||||||
msgstr "Suprimir la inscripción WEI"
|
msgstr "Suprimir la inscripción WEI"
|
||||||
|
|
||||||
#: apps/wei/views.py:881
|
#: apps/wei/views.py:887
|
||||||
msgid "You don't have the right to delete this WEI registration."
|
msgid "You don't have the right to delete this WEI registration."
|
||||||
msgstr "Usted no tiene derecho a suprimir esta inscripción WEI."
|
msgstr "Usted no tiene derecho a suprimir esta inscripción WEI."
|
||||||
|
|
||||||
#: apps/wei/views.py:899
|
#: apps/wei/views.py:905
|
||||||
msgid "Validate WEI registration"
|
msgid "Validate WEI registration"
|
||||||
msgstr "Validar la inscripción WEI"
|
msgstr "Validar la inscripción WEI"
|
||||||
|
|
||||||
#: apps/wei/views.py:985
|
#: apps/wei/views.py:998
|
||||||
msgid "Please make sure the check is given before validating the registration"
|
msgid "Please make sure the check is given before validating the registration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Por favor asegúrese de que el cheque se entrega antes de validar el registro"
|
"Por favor asegúrese de que el cheque se entrega antes de validar el registro"
|
||||||
|
|
||||||
#: apps/wei/views.py:991
|
#: apps/wei/views.py:1004
|
||||||
#| msgid "credit transaction"
|
|
||||||
msgid "Create deposit transaction"
|
msgid "Create deposit transaction"
|
||||||
msgstr "Crear transacción de crédito"
|
msgstr "Crear transacción de crédito"
|
||||||
|
|
||||||
#: apps/wei/views.py:992
|
#: apps/wei/views.py:1005
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
"A transaction of %(amount).2f€ will be created from the user's Note account"
|
"A transaction of %(amount).2f€ will be created from the user's Note account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/wei/views.py:1087
|
#: apps/wei/views.py:1093
|
||||||
#, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid ""
|
#| msgid ""
|
||||||
#| "This user don't have enough money to join this club, and can't have a "
|
#| "This user doesn't have enough money. Current balance: %(balance)d€, "
|
||||||
#| "negative balance."
|
#| "credit: %(credit)d€, needed: %(needed)d€"
|
||||||
msgid ""
|
msgid ""
|
||||||
"This user doesn't have enough money. "
|
"This user doesn't have enough money to join this club and pay the deposit. "
|
||||||
"Current balance: %(balance)d€, credit: %(credit)d€, needed: %(needed)d€"
|
"Current balance: %(balance)d€, credit: %(credit)d€, needed: %(needed)d€"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Este usuario no tiene suficiente dinero. "
|
"Este usuario no tiene suficiente dinero. Saldo actual : %(balance)d€, "
|
||||||
"Saldo actual : %(balance)d€, crédito: %(credit)d€, requerido: %(needed)d€"
|
"crédito: %(credit)d€, requerido: %(needed)d€"
|
||||||
|
|
||||||
#: apps/wei/views.py:1140
|
#: apps/wei/views.py:1146
|
||||||
#, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "created at"
|
#| msgid "Caution %(name)s"
|
||||||
msgid "Caution %(name)s"
|
msgid "Deposit %(name)s"
|
||||||
msgstr "Fianza %(name)s"
|
msgstr "Fianza %(name)s"
|
||||||
|
|
||||||
#: apps/wei/views.py:1354
|
#: apps/wei/views.py:1360
|
||||||
msgid "Attribute buses to first year members"
|
msgid "Attribute buses to first year members"
|
||||||
msgstr "Repartir los primer años en los buses"
|
msgstr "Repartir los primer años en los buses"
|
||||||
|
|
||||||
#: apps/wei/views.py:1379
|
#: apps/wei/views.py:1386
|
||||||
msgid "Attribute bus"
|
msgid "Attribute bus"
|
||||||
msgstr "Repartir en un bus"
|
msgstr "Repartir en un bus"
|
||||||
|
|
||||||
#: apps/wei/views.py:1419
|
#: apps/wei/views.py:1426
|
||||||
msgid ""
|
msgid ""
|
||||||
"No first year student without a bus found. Either all of them have a bus, or "
|
"No first year student without a bus found. Either all of them have a bus, or "
|
||||||
"none has filled the survey yet."
|
"none has filled the survey yet."
|
||||||
@@ -4337,6 +4358,24 @@ msgstr ""
|
|||||||
"pagar su afiliación. Tambien tiene que validar su correo electronico con el "
|
"pagar su afiliación. Tambien tiene que validar su correo electronico con el "
|
||||||
"enlace que recibió."
|
"enlace que recibió."
|
||||||
|
|
||||||
|
#, fuzzy
|
||||||
|
#~| msgid "total amount"
|
||||||
|
#~ msgid "caution amount"
|
||||||
|
#~ msgstr "monto total"
|
||||||
|
|
||||||
|
#, fuzzy
|
||||||
|
#~| msgid "created at"
|
||||||
|
#~ msgid "caution type"
|
||||||
|
#~ msgstr "tipo de fianza"
|
||||||
|
|
||||||
|
#, fuzzy
|
||||||
|
#~| msgid "total amount"
|
||||||
|
#~ msgid "Caution amount"
|
||||||
|
#~ msgstr "monto total"
|
||||||
|
|
||||||
|
#~ msgid "caution check given"
|
||||||
|
#~ msgstr "cheque de garantía dado"
|
||||||
|
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#~| msgid "Invitation"
|
#~| msgid "Invitation"
|
||||||
#~ msgid "Syndication"
|
#~ msgid "Syndication"
|
||||||
|
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