diff --git a/.env_example b/.env_example index 7e1dbd3b..da0b4efa 100644 --- a/.env_example +++ b/.env_example @@ -21,3 +21,6 @@ EMAIL_PASSWORD=CHANGE_ME # Wiki configuration WIKI_USER=NoteKfet2020 WIKI_PASSWORD= + +# OIDC +OIDC_RSA_PRIVATE_KEY=CHANGE_ME diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2ba35d31..4cf8dab9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,7 +8,7 @@ variables: GIT_SUBMODULE_STRATEGY: recursive # Ubuntu 22.04 -py310-django42: +py310-django52: stage: test image: ubuntu:22.04 before_script: @@ -22,10 +22,10 @@ py310-django42: python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache python3-bs4 python3-setuptools tox texlive-xetex - script: tox -e py310-django42 + script: tox -e py310-django52 # Debian Bookworm -py311-django42: +py311-django52: stage: test image: debian:bookworm before_script: @@ -37,7 +37,7 @@ py311-django42: python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache python3-bs4 python3-setuptools tox texlive-xetex - script: tox -e py311-django42 + script: tox -e py311-django52 linters: stage: quality-assurance diff --git a/README.md b/README.md index 4ba19356..c340d58c 100644 --- a/README.md +++ b/README.md @@ -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** 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 -emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`). +exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et copier la clé dans .env dans le champ +`OIDC_RSA_PRIVATE_KEY`. 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** 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 -emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`). +exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner le champ +`OIDC_RSA_PRIVATE_KEY` dans le .env (par défaut `/var/secrets/oidc.key`). 8. *Enjoy \o/* diff --git a/apps/activity/forms.py b/apps/activity/forms.py index 305c4f03..a865ece6 100644 --- a/apps/activity/forms.py +++ b/apps/activity/forms.py @@ -32,7 +32,7 @@ class ActivityForm(forms.ModelForm): def clean_organizer(self): organizer = self.cleaned_data['organizer'] if not organizer.note.is_active: - self.add_error('organiser', _('The note of this club is inactive.')) + self.add_error('organizer', _('The note of this club is inactive.')) return organizer def clean_date_end(self): diff --git a/apps/activity/migrations/0007_alter_guest_activity.py b/apps/activity/migrations/0007_alter_guest_activity.py new file mode 100644 index 00000000..9badcc1b --- /dev/null +++ b/apps/activity/migrations/0007_alter_guest_activity.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.20 on 2025-05-08 19:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0006_guest_school'), + ] + + operations = [ + migrations.AlterField( + model_name='guest', + name='activity', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='activity.activity'), + ), + ] diff --git a/apps/activity/models.py b/apps/activity/models.py index c7c92e8d..4e313a57 100644 --- a/apps/activity/models.py +++ b/apps/activity/models.py @@ -234,7 +234,7 @@ class Guest(models.Model): """ activity = models.ForeignKey( Activity, - on_delete=models.PROTECT, + on_delete=models.CASCADE, related_name='+', ) diff --git a/apps/activity/templates/activity/activity_detail.html b/apps/activity/templates/activity/activity_detail.html index a94d1e37..bb0fc57a 100644 --- a/apps/activity/templates/activity/activity_detail.html +++ b/apps/activity/templates/activity/activity_detail.html @@ -37,6 +37,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% render_table guests %}
+ {% endif %} {% endblock %} @@ -95,5 +100,23 @@ SPDX-License-Identifier: GPL-3.0-or-later errMsg(xhr.responseJSON); }); }); + $("#delete_activity").click(function () { + if (!confirm("{% trans 'Are you sure you want to delete this activity?' %}")) { + return; + } + + $.ajax({ + url: "/api/activity/activity/{{ activity.pk }}/", + type: "DELETE", + headers: { + "X-CSRFTOKEN": CSRF_TOKEN + } + }).done(function () { + addMsg("{% trans 'Activity deleted' %}", "success"); + window.location.href = "/activity/"; // Redirige vers la liste des activités + }).fail(function (xhr) { + errMsg(xhr.responseJSON); + }); + }); {% endblock %} diff --git a/apps/activity/templates/activity/activity_list.html b/apps/activity/templates/activity/activity_list.html index bf5ba70f..79da6d97 100644 --- a/apps/activity/templates/activity/activity_list.html +++ b/apps/activity/templates/activity/activity_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base_search.html" %} {% comment %} SPDX-License-Identifier: GPL-3.0-or-later {% endcomment %} @@ -44,6 +44,8 @@ SPDX-License-Identifier: GPL-3.0-or-later

{% trans "All activities" %}

- {% render_table table %} + {% render_table all %} + +{{ block.super }} {% endblock %} diff --git a/apps/activity/templates/activity/includes/activity_info.html b/apps/activity/templates/activity/includes/activity_info.html index a16ad33b..4565a086 100644 --- a/apps/activity/templates/activity/includes/activity_info.html +++ b/apps/activity/templates/activity/includes/activity_info.html @@ -1,7 +1,7 @@ {% comment %} SPDX-License-Identifier: GPL-3.0-or-later {% endcomment %} -{% load i18n perms pretty_money %} +{% load i18n perms pretty_money dict_get %} {% url 'activity:activity_detail' activity.pk as activity_detail_url %}
@@ -53,6 +53,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans 'opened'|capfirst %}
{{ activity.open|yesno }}
+ {% if show_entries|dict_get:activity %} +

+ {{ entries_count|dict_get:activity }} + {% if entries_count|dict_get:activity >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %} +

+ {% endif %}
+ + + + +{% endblock %} \ No newline at end of file diff --git a/apps/food/templates/food/basicfood_form.html b/apps/food/templates/food/food_update.html similarity index 92% rename from apps/food/templates/food/basicfood_form.html rename to apps/food/templates/food/food_update.html index 6fe6f06f..67de3e27 100644 --- a/apps/food/templates/food/basicfood_form.html +++ b/apps/food/templates/food/food_update.html @@ -1,5 +1,6 @@ {% 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 %} diff --git a/apps/food/templates/food/manage_ingredients.html b/apps/food/templates/food/manage_ingredients.html new file mode 100644 index 00000000..0dd7acb5 --- /dev/null +++ b/apps/food/templates/food/manage_ingredients.html @@ -0,0 +1,116 @@ +{% 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 %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + + {# Fill initial data #} + {% for display, form in formset %} + {% if forloop.first %} + + + + + + + + + {% endif %} + {% if display %} + + {% else %} + + {% endif %} + + + + + {% endfor %} + +
{{ form.name.label }}{{ form.qrcode.label }}{{ form.fully_used.label }}
+ + {# Display buttons to add and remove ingredients #} +
+
+ + +
+ +
+
+
+ +{% endblock %} +{% block extrajavascript %} + +{% endblock %} diff --git a/apps/food/templates/food/qrcode.html b/apps/food/templates/food/qrcode.html new file mode 100644 index 00000000..49c9eccb --- /dev/null +++ b/apps/food/templates/food/qrcode.html @@ -0,0 +1,52 @@ +{% 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 %} +{% load render_table from django_tables2 %} + +{% block content %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + +
+
+

+ {% trans "Copy constructor" %} + {% trans "New food" %} +

+ + + + + + + + + + {% for food in last_items %} + + + + + + {% endfor %} + +
+ {% trans "Name" %} + + {% trans "Owner" %} + + {% trans "Expiry date" %} +
{{ food.name }}{{ food.owner }}{{ food.expiry_date }}
+
+
+
+{% endblock %} diff --git a/apps/food/templates/food/qrcode_detail.html b/apps/food/templates/food/qrcode_detail.html deleted file mode 100644 index 6e3e8110..00000000 --- a/apps/food/templates/food/qrcode_detail.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load i18n crispy_forms_tags %} - -{% block content %} -
-

- {{ title }} {% trans 'number' %} {{ qrcode.qr_code_number }} -

-
- - {% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %} - - {% trans 'Update' %} - - {% elif can_update_transformed %} - - {% trans 'Update' %} - - {% endif %} - {% if can_view_detail %} - - {% trans 'View details' %} - - {% endif %} - {% if can_add_ingredient %} - - {% trans 'Add to a meal' %} - - {% endif %} -
-
-{% endblock %} diff --git a/apps/food/templates/food/transformedfood_detail.html b/apps/food/templates/food/transformedfood_detail.html deleted file mode 100644 index ca32bc06..00000000 --- a/apps/food/templates/food/transformedfood_detail.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load i18n crispy_forms_tags %} - -{% block content %} -
-

- {{ title }} {{ food.name }} -

-
- - {% if can_update %} - - {% trans 'Update' %} - - {% endif %} - {% if can_add_ingredient %} - - {% trans 'Add to a meal' %} - - {% endif %} -
-
-{% endblock %} diff --git a/apps/food/templates/food/transformedfood_list.html b/apps/food/templates/food/transformedfood_list.html deleted file mode 100644 index 4416cdb7..00000000 --- a/apps/food/templates/food/transformedfood_list.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load render_table from django_tables2 %} -{% load i18n %} - -{% block content %} -
-

- {% trans "Meal served" %} -

- {% if can_create_meal %} - - {% endif %} - {% if served.data %} - {% render_table served %} - {% else %} -
-
- {% trans "There is no meal served." %} -
-
- {% endif %} -
- -
-

- {% trans "Open" %} -

- {% if open.data %} - {% render_table open %} - {% else %} -
-
- {% trans "There is no free meal." %} -
-
- {% endif %} -
- -
-

- {% trans "All meals" %} -

- {% if table.data %} - {% render_table table %} - {% else %} -
-
- {% trans "There is no meal." %} -
-
- {% endif %} -
-{% endblock %} diff --git a/apps/food/templates/food/transformedfood_update.html b/apps/food/templates/food/transformedfood_update.html new file mode 100644 index 00000000..820970b7 --- /dev/null +++ b/apps/food/templates/food/transformedfood_update.html @@ -0,0 +1,87 @@ +{% 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 %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + + {# Fill initial data #} + {% for ingredient_form in formset %} + {% if forloop.first %} + + + + + + + + {% endif %} + + {{ ingredient_form | crispy }} + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "QR-code number" %}{% trans "Fully used" %} +
{{ ingredient_form.name }}{{ ingredient_form.qrcode }}{{ ingredient_form.fully_used }}
+ {# Display buttons to add and remove products #} +
+
+ + +
+ +
+
+
+
+ +{# Hidden div that store an empty product form, to be copied into new forms #} + +{% endblock %} +{% block extrajavascript %} + +{% endblock %} diff --git a/apps/food/tests.py b/apps/food/tests.py deleted file mode 100644 index a79ca8be..00000000 --- a/apps/food/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.test import TestCase - -# Create your tests here. diff --git a/apps/food/tests/test_food.py b/apps/food/tests/test_food.py new file mode 100644 index 00000000..9c314bf7 --- /dev/null +++ b/apps/food/tests/test_food.py @@ -0,0 +1,170 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from api.tests import TestAPI +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet +from ..models import Allergen, BasicFood, TransformedFood, QRCode + + +class TestFood(TestCase): + """ + Test food + """ + fixtures = ('initial',) + + 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.allergen = Allergen.objects.create( + name='allergen', + ) + + self.basicfood = BasicFood.objects.create( + name='basicfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + date_type='DLC', + ) + + self.transformedfood = TransformedFood.objects.create( + name='transformedfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + ) + + self.qrcode = QRCode.objects.create( + qr_code_number=1, + food_container=self.basicfood, + ) + + def test_food_list(self): + """ + Display food list + """ + response = self.client.get(reverse('food:food_list')) + self.assertEqual(response.status_code, 200) + + def test_qrcode_create(self): + """ + Display QRCode creation + """ + response = self.client.get(reverse('food:qrcode_create')) + self.assertEqual(response.status_code, 200) + + def test_basicfood_create(self): + """ + Display BasicFood creation + """ + response = self.client.get(reverse('food:basicfood_create')) + self.assertEqual(response.status_code, 200) + + def test_transformedfood_create(self): + """ + Display TransformedFood creation + """ + response = self.client.get(reverse('food:transformedfood_create')) + self.assertEqual(response.status_code, 200) + + def test_food_create(self): + """ + Display Food update + """ + response = self.client.get(reverse('food:food_update')) + self.assertEqual(response.status_code, 200) + + def test_food_view(self): + """ + Display Food detail + """ + response = self.client.get(reverse('food:food_view')) + self.assertEqual(response.status_code, 302) + + def test_basicfood_view(self): + """ + Display BasicFood detail + """ + response = self.client.get(reverse('food:basicfood_view')) + self.assertEqual(response.status_code, 200) + + def test_transformedfood_view(self): + """ + Display TransformedFood detail + """ + response = self.client.get(reverse('food:transformedfood_view')) + self.assertEqual(response.status_code, 200) + + def test_add_ingredient(self): + """ + Display add ingredient view + """ + response = self.client.get(reverse('food:add_ingredient')) + self.assertEqual(response.status_code, 200) + + +class TestFoodAPI(TestAPI): + def setUp(self) -> None: + super().setUP() + + self.allergen = Allergen.objects.create( + name='name', + ) + + self.basicfood = BasicFood.objects.create( + name='basicfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + date_type='DLC', + ) + + self.transformedfood = TransformedFood.objects.create( + name='transformedfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + ) + + self.qrcode = QRCode.objects.create( + qr_code_number=1, + food_container=self.basicfood, + ) + + def test_allergen_api(self): + """ + Load Allergen API page and test all filters and permissions + """ + self.check_viewset(AllergenViewSet, '/api/food/allergen/') + + def test_basicfood_api(self): + """ + Load BasicFood API page and test all filters and permissions + """ + self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/') + + def test_transformedfood_api(self): + """ + Load TransformedFood API page and test all filters and permissions + """ + self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/') + + def test_qrcode_api(self): + """ + Load QRCode API page and test all filters and permissions + """ + self.check_viewset(QRCodeViewSet, '/api/food/qrcode/') diff --git a/apps/food/urls.py b/apps/food/urls.py index 59063cfe..82a7f22e 100644 --- a/apps/food/urls.py +++ b/apps/food/urls.py @@ -8,14 +8,15 @@ from . import views app_name = 'food' urlpatterns = [ - path('', views.TransformedListView.as_view(), name='food_list'), - path('', views.QRCodeView.as_view(), name='qrcode_view'), - path('detail/', views.FoodView.as_view(), name='food_view'), - - path('/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'), - path('/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'), - path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'), - path('update/basic/', views.BasicFoodUpdateView.as_view(), name='basic_update'), - path('update/transformed/', views.TransformedFoodUpdateView.as_view(), name='transformed_update'), - path('add/', views.AddIngredientView.as_view(), name='add_ingredient'), + path('', views.FoodListView.as_view(), name='food_list'), + path('', views.QRCodeCreateView.as_view(), name='qrcode_create'), + path('/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'), + path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'), + path('update/', views.FoodUpdateView.as_view(), name='food_update'), + path('update/ingredients/', views.ManageIngredientsView.as_view(), name='manage_ingredients'), + path('detail/', views.FoodDetailView.as_view(), name='food_view'), + path('detail/basic/', views.BasicFoodDetailView.as_view(), name='basicfood_view'), + path('detail/transformed/', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'), + path('add/ingredient/', views.AddIngredientView.as_view(), name='add_ingredient'), + path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'), ] diff --git a/apps/food/utils.py b/apps/food/utils.py new file mode 100644 index 00000000..a08d949a --- /dev/null +++ b/apps/food/utils.py @@ -0,0 +1,53 @@ +# 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 _ + +seconds = (_('second'), _('seconds')) +minutes = (_('minute'), _('minutes')) +hours = (_('hour'), _('hours')) +days = (_('day'), _('days')) +weeks = (_('week'), _('weeks')) + + +def plural(x): + if x == 1: + return 0 + return 1 + + +def pretty_duration(duration): + """ + I receive datetime.timedelta object + You receive string object + """ + text = [] + sec = duration.seconds + d = duration.days + + if d >= 7: + w = d // 7 + text.append(str(w) + ' ' + weeks[plural(w)]) + d -= w * 7 + if d > 0: + text.append(str(d) + ' ' + days[plural(d)]) + + if sec >= 3600: + h = sec // 3600 + text.append(str(h) + ' ' + hours[plural(h)]) + sec -= h * 3600 + + if sec >= 60: + m = sec // 60 + text.append(str(m) + ' ' + minutes[plural(m)]) + sec -= m * 60 + + if sec > 0: + text.append(str(sec) + ' ' + seconds[plural(sec)]) + + if len(text) == 0: + return '' + if len(text) == 1: + return text[0] + if len(text) >= 2: + return ', '.join(t for t in text[:-1]) + ' ' + _('and') + ' ' + text[-1] diff --git a/apps/food/views.py b/apps/food/views.py index 8c63530c..2ee8c998 100644 --- a/apps/food/views.py +++ b/apps/food/views.py @@ -1,421 +1,532 @@ # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.db import transaction -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponseRedirect +from datetime import timedelta + +from api.viewsets import is_regex from django_tables2.views import MultiTableMixin -from django.urls import reverse -from django.utils.translation import gettext_lazy as _ -from django.utils import timezone -from django.views.generic import DetailView, UpdateView +from django.db import transaction +from django.db.models import Q +from django.http import HttpResponseRedirect, Http404 +from django.views.generic import DetailView, UpdateView, CreateView from django.views.generic.list import ListView -from django.forms import HiddenInput +from django.views.generic.base import RedirectView +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from member.models import Club, Membership from permission.backends import PermissionBackend -from permission.views import ProtectQuerysetMixin, ProtectedCreateView +from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin -from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms -from .models import BasicFood, Food, QRCode, TransformedFood -from .tables import TransformedFoodTable +from .models import Food, BasicFood, TransformedFood, QRCode +from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ + ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ + BasicFoodUpdateForms, TransformedFoodUpdateForms +from .tables import FoodTable +from .utils import pretty_duration -class AddIngredientView(ProtectQuerysetMixin, UpdateView): +class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): """ - A view to add an ingredient + Display Food """ model = Food - template_name = 'food/add_ingredient_form.html' - extra_context = {"title": _("Add the ingredient")} - form_class = AddIngredientForms + tables = [FoodTable, FoodTable, FoodTable, ] + extra_context = {"title": _('Food')} + template_name = 'food/food_list.html' - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["pk"] = self.kwargs["pk"] - return context + def get_queryset(self, **kwargs): + return super().get_queryset(**kwargs).distinct() - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - food = Food.objects.get(pk=self.kwargs['pk']) - add_ingredient_form = AddIngredientForms(data=self.request.POST) - if food.is_ready: - form.add_error(None, _("The product is already prepared")) - return self.form_invalid(form) - if not add_ingredient_form.is_valid(): - return self.form_invalid(form) + def get_tables(self): + bureau_role_pk = 4 + clubs = Club.objects.filter(membership__in=Membership.objects.filter( + user=self.request.user, roles=bureau_role_pk).filter( + date_end__gte=timezone.now())) - # We flip logic ""fully used = not is_active"" - food.is_active = not food.is_active - # Save the aliment and the allergens associed - for transformed_pk in self.request.POST.getlist('ingredient'): - transformed = TransformedFood.objects.get(pk=transformed_pk) - if not transformed.is_ready: - transformed.ingredient.add(food) - transformed.update() - food.save() + tables = [FoodTable] * (clubs.count() + 3) + self.tables = tables + tables = super().get_tables() + tables[0].prefix = 'search-' + tables[1].prefix = 'open-' + tables[2].prefix = 'served-' + for i in range(clubs.count()): + tables[i + 3].prefix = clubs[i].name + return tables - return HttpResponseRedirect(self.get_success_url()) + def get_tables_data(self): + # table search + qs = self.get_queryset().order_by('name') + if "search" in self.request.GET and self.request.GET['search']: + pattern = self.request.GET['search'] - def get_success_url(self, **kwargs): - return reverse('food:food_list') + # 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'owner__name{suffix}': prefix + pattern}) + | Q(**{f'owner__note__alias__name{suffix}': prefix + pattern})) + else: + 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')) + # table open + open_table = self.get_queryset().filter( + Q(polymorphic_ctype__model='transformedfood') + | Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter( + expiry_date__lt=timezone.now(), end_of_life='').filter( + 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 + served_table = self.get_queryset().order_by('-pk').filter( + end_of_life='', is_ready=True).exclude( + Q(polymorphic_ctype__model='basicfood', + basicfood__date_type='DLC', + expiry_date__lte=timezone.now(),) + | Q(polymorphic_ctype__model='transformedfood', + expiry_date__lte=timezone.now(), + )) + # tables club + bureau_role_pk = 4 + clubs = Club.objects.filter(membership__in=Membership.objects.filter( + user=self.request.user, roles=bureau_role_pk).filter( + date_end__gte=timezone.now())) + club_table = [] + for club in clubs: + club_table.append(self.get_queryset().order_by('expiry_date').filter( + owner=club, end_of_life='').filter( + PermissionBackend.filter_queryset(self.request, Food, 'view') + )) -class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): - """ - A view to update a basic food - """ - model = BasicFood - form_class = BasicFoodForms - template_name = 'food/basicfood_form.html' - extra_context = {"title": _("Update an aliment")} - - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - basic_food_form = BasicFoodForms(data=self.request.POST) - if not basic_food_form.is_valid(): - return self.form_invalid(form) - - ans = super().form_valid(form) - form.instance.update() - return ans - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse('food:food_view', kwargs={"pk": self.object.pk}) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - return context - - -class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): - """ - A view to see a food - """ - model = Food - extra_context = {"title": _("Details of:")} - context_object_name = "food" + return [search_table, open_table, served_table] + club_table def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food") - context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood") + tables = context['tables'] + # for extends base_search.html we need to name 'search_table' in 'table' + for name, table in zip(['table', 'open', 'served'], tables): + context[name] = table + context['club_tables'] = tables[3:] + + context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add') return context -class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): - ##################################################################### - # TO DO - # - this feature is very pratical for meat or fish, nevertheless we can implement this later - # - fix picture save - # - implement solution crop and convert image (reuse or recode ImageForm from members apps) - ##################################################################### +class QRCodeCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): """ - A view to add a basic food with a qrcode - """ - model = BasicFood - form_class = BasicFoodForms - template_name = 'food/basicfood_form.html' - extra_context = {"title": _("Add a new basic food with QRCode")} - - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - basic_food_form = BasicFoodForms(data=self.request.POST) - if not basic_food_form.is_valid(): - return self.form_invalid(form) - - # Save the aliment and the allergens associed - basic_food = form.save(commit=False) - # We assume the date of labeling and the same as the date of arrival - basic_food.arrival_date = timezone.now() - basic_food.is_ready = False - basic_food.is_active = True - basic_food.was_eaten = False - basic_food._force_save = True - basic_food.save() - basic_food.refresh_from_db() - - qrcode = QRCode() - qrcode.qr_code_number = self.kwargs['slug'] - qrcode.food_container = basic_food - qrcode.save() - - return super().form_valid(form) - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']}) - - def get_sample_object(self): - - # We choose a club which may work or BDE else - owner_id = 1 - for membership in self.request.user.memberships.all(): - club_id = membership.club.id - food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id) - if PermissionBackend.check_perm(self.request, "food.add_basicfood", food): - owner_id = club_id - - return BasicFood( - name="", - expiry_date=timezone.now(), - owner_id=owner_id, - ) - - def get_context_data(self, **kwargs): - # Some field are hidden on create - context = super().get_context_data(**kwargs) - - form = context['form'] - form.fields['is_active'].widget = HiddenInput() - form.fields['was_eaten'].widget = HiddenInput() - - copy = self.request.GET.get('copy', None) - if copy is not None: - basic = BasicFood.objects.get(pk=copy) - for field in ['date_type', 'expiry_date', 'name', 'owner']: - form.fields[field].initial = getattr(basic, field) - for field in ['allergens']: - form.fields[field].initial = getattr(basic, field).all() - - return context - - -class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView): - """ - A view to add a new qrcode + A view to add qrcode """ model = QRCode - template_name = 'food/create_qrcode_form.html' + template_name = 'food/qrcode.html' form_class = QRCodeForms extra_context = {"title": _("Add a new QRCode")} def get(self, *args, **kwargs): qrcode = kwargs["slug"] if self.model.objects.filter(qr_code_number=qrcode).count() > 0: - return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs)) + pk = self.model.objects.get(qr_code_number=qrcode).food_container.pk + return HttpResponseRedirect(reverse_lazy("food:food_view", kwargs={"pk": pk})) else: return super().get(*args, **kwargs) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["slug"] = self.kwargs["slug"] - - context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10] - - return context - @transaction.atomic def form_valid(self, form): - form.instance.creater = self.request.user qrcode_food_form = QRCodeForms(data=self.request.POST) if not qrcode_food_form.is_valid(): return self.form_invalid(form) - # Save the qrcode qrcode = form.save(commit=False) - qrcode.qr_code_number = self.kwargs["slug"] + qrcode.qr_code_number = self.kwargs['slug'] qrcode._force_save = True qrcode.save() qrcode.refresh_from_db() + return super().form_valid(form) - qrcode.food_container.save() + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['slug'] = self.kwargs['slug'] + + # get last 10 BasicFood objects with distincts 'name' ordered by '-pk' + # we can't use .distinct and .order_by with differents columns hence the generator + context['last_items'] = [food for food in BasicFood.get_lastests_objects(10, 'name', '-pk')] + return context + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('food:food_view', kwargs={'pk': self.object.food_container.pk}) + + def get_sample_object(self): + return QRCode( + qr_code_number=self.kwargs['slug'], + food_container_id=1, + ) + + +class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + A view to add basicfood + """ + model = BasicFood + form_class = BasicFoodForms + extra_context = {"title": _("Add an aliment")} + template_name = "food/food_update.html" + + def get_sample_object(self): + # We choose a club which may work or BDE else + food = BasicFood( + name="", + owner_id=1, + expiry_date=timezone.now(), + is_ready=True, + arrival_date=timezone.now(), + date_type='DLC', + ) + + for membership in self.request.user.memberships.all(): + club_id = membership.club.id + food.owner_id = club_id + if PermissionBackend.check_perm(self.request, "food.add_basicfood", food): + return food + + return food + + @transaction.atomic + def form_valid(self, form): + if QRCode.objects.filter(qr_code_number=self.kwargs['slug']).count() > 0: + return HttpResponseRedirect(reverse_lazy('food:qrcode_create', kwargs={'slug': self.kwargs['slug']})) + food_form = BasicFoodForms(data=self.request.POST) + if not food_form.is_valid(): + return self.form_invalid(form) + + food = form.save(commit=False) + food.is_ready = False + food.save() + food.refresh_from_db() + + qrcode = QRCode() + qrcode.qr_code_number = self.kwargs['slug'] + qrcode.food_container = food + qrcode.save() return super().form_valid(form) def get_success_url(self, **kwargs): self.object.refresh_from_db() - return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']}) + return reverse_lazy('food:basicfood_view', kwargs={"pk": self.object.pk}) - def get_sample_object(self): - return QRCode( - qr_code_number=self.kwargs["slug"], - food_container_id=1 - ) + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + copy = self.request.GET.get('copy', None) + if copy is not None: + food = BasicFood.objects.get(pk=copy) -class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): - """ - A view to see a qrcode - """ - model = QRCode - extra_context = {"title": _("QRCode")} - context_object_name = "qrcode" - slug_field = "qr_code_number" + for field in context['form'].fields: + if field == 'allergens': + context['form'].fields[field].initial = getattr(food, field).all() + else: + context['form'].fields[field].initial = getattr(food, field) - def get(self, *args, **kwargs): - qrcode = kwargs["slug"] - if self.model.objects.filter(qr_code_number=qrcode).count() > 0: - return super().get(*args, **kwargs) - else: - return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs)) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - qr_code_number = self.kwargs['slug'] - qrcode = self.model.objects.get(qr_code_number=qr_code_number) - - model = qrcode.food_container.polymorphic_ctype.model - - if model == "basicfood": - context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood") - context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood") - if model == "transformedfood": - context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood") - context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood") - context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood") return context class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): """ - A view to add a tranformed food + A view to add transformedfood """ model = TransformedFood - template_name = 'food/transformedfood_form.html' form_class = TransformedFoodForms - extra_context = {"title": _("Add a new meal")} - - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - transformed_food_form = TransformedFoodForms(data=self.request.POST) - if not transformed_food_form.is_valid(): - return self.form_invalid(form) - - # Save the aliment and allergens associated - transformed_food = form.save(commit=False) - transformed_food.expiry_date = transformed_food.creation_date - transformed_food.is_active = True - transformed_food.is_ready = False - transformed_food.was_eaten = False - transformed_food._force_save = True - transformed_food.save() - transformed_food.refresh_from_db() - ans = super().form_valid(form) - transformed_food.update() - return ans - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse('food:food_view', kwargs={"pk": self.object.pk}) + extra_context = {"title": _("Add a meal")} + template_name = "food/food_update.html" def get_sample_object(self): # We choose a club which may work or BDE else - owner_id = 1 - for membership in self.request.user.memberships.all(): - club_id = membership.club.id - food = TransformedFood(name="", - creation_date=timezone.now(), - expiry_date=timezone.now(), - owner_id=club_id) - if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food): - owner_id = club_id - break - - return TransformedFood( + food = TransformedFood( name="", - owner_id=owner_id, - creation_date=timezone.now(), + owner_id=1, expiry_date=timezone.now(), + is_ready=True, ) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + for membership in self.request.user.memberships.all(): + club_id = membership.club.id + food.owner_id = club_id + if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food): + return food - # Some field are hidden on create - form = context['form'] - form.fields['is_active'].widget = HiddenInput() - form.fields['is_ready'].widget = HiddenInput() - form.fields['was_eaten'].widget = HiddenInput() - form.fields['shelf_life'].widget = HiddenInput() + return food - return context + @transaction.atomic + def form_valid(self, form): + form.instance.expiry_date = timezone.now() + timedelta(days=3) + form.instance.is_ready = False + return super().form_valid(form) + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk}) -class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): +MAX_FORMS = 100 + + +class ManageIngredientsView(LoginRequiredMixin, UpdateView): """ - A view to update transformed product + A view to manage ingredient for a transformed food """ model = TransformedFood - template_name = 'food/transformedfood_form.html' - form_class = TransformedFoodForms - extra_context = {'title': _('Update a meal')} + fields = ['ingredients'] + extra_context = {"title": _("Manage ingredients of:")} + template_name = 'food/manage_ingredients.html' + + @transaction.atomic + def form_valid(self, form): + old_ingredients = list(self.object.ingredients.all()).copy() + old_allergens = list(self.object.allergens.all()).copy() + self.object.ingredients.clear() + for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS): + prefix = 'form-' + str(i) + '-' + if form.data[prefix + 'qrcode'] not in ['0', '']: + ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container + self.object.ingredients.add(ingredient) + if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on': + ingredient.end_of_life = _('Fully used in {meal}'.format( + meal=self.object.name)) + ingredient.save() + + elif form.data[prefix + 'name'] != '': + ingredient = Food.objects.get(pk=form.data[prefix + 'name']) + self.object.ingredients.add(ingredient) + if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on': + ingredient.end_of_life = _('Fully used in {meal}'.format( + meal=self.object.name)) + ingredient.save() + # We recalculate new expiry date and allergens + self.object.expiry_date = self.object.creation_date + self.object.shelf_life + self.object.allergens.clear() + + for ingredient in self.object.ingredients.iterator(): + if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'): + self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date) + self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all())) + + self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens) + return HttpResponseRedirect(self.get_success_url()) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['title'] += ' ' + self.object.name + formset = ManageIngredientsFormSet() + ingredients = self.object.ingredients.all() + formset.extra += ingredients.count() + MAX_FORMS + context['form'] = ManageIngredientsForm() + context['ingredients_count'] = ingredients.count() + display = [True] * (1 + ingredients.count()) + [False] * (formset.extra - ingredients.count() - 1) + context['formset'] = zip(display, formset) + context['ingredients'] = [] + for ingredient in ingredients: + qr = QRCode.objects.filter(food_container=ingredient) + + context['ingredients'].append({ + 'food_pk': ingredient.pk, + 'food_name': ingredient.name, + 'qr_pk': '' if qr.count() == 0 else qr[0].pk, + 'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number, + 'fully_used': 'true' if ingredient.end_of_life else '', + }) + return context + + def get_success_url(self, **kwargs): + return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk}) + + +class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + """ + A view to add ingredient to a meal + """ + model = Food + extra_context = {"title": _("Add the ingredient:")} + form_class = AddIngredientForms + template_name = 'food/food_update.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['title'] += ' ' + self.object.name + return context + + @transaction.atomic + def form_valid(self, form): + meals = TransformedFood.objects.filter(pk__in=form.data.getlist('ingredients')).all() + if not meals: + return HttpResponseRedirect(reverse_lazy('food:food_view', kwargs={"pk": self.object.pk})) + for meal in meals: + old_ingredients = list(meal.ingredients.all()).copy() + old_allergens = list(meal.allergens.all()).copy() + meal.ingredients.add(self.object.pk) + # update allergen and expiry date if necessary + if not (self.object.polymorphic_ctype.model == 'basicfood' + and self.object.date_type == 'DDM'): + meal.expiry_date = min(meal.expiry_date, self.object.expiry_date) + meal.allergens.set(meal.allergens.union(self.object.allergens.all())) + meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens) + if 'fully_used' in form.data: + if not self.object.end_of_life: + self.object.end_of_life = _(f'Food fully used in : {meal.name}') + else: + self.object.end_of_life += ', ' + meal.name + if 'fully_used' in form.data: + self.object.is_ready = False + self.object.save() + # We redirect only the first parent + parent_pk = meals[0].pk + return HttpResponseRedirect(self.get_success_url(parent_pk=parent_pk)) + + def get_success_url(self, **kwargs): + return reverse_lazy('food:transformedfood_view', kwargs={"pk": kwargs['parent_pk']}) + + +class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + """ + A view to update Food + """ + model = Food + extra_context = {"title": _("Update an aliment")} + template_name = 'food/food_update.html' @transaction.atomic def form_valid(self, form): form.instance.creater = self.request.user - transformedfood_form = TransformedFoodForms(data=self.request.POST) - if not transformedfood_form.is_valid(): - return self.form_invalid(form) + food = Food.objects.get(pk=self.kwargs['pk']) + old_allergens = list(food.allergens.all()).copy() + if food.polymorphic_ctype.model == 'transformedfood': + old_ingredients = food.ingredients.all() + form.instance.shelf_life = timedelta( + seconds=int(form.data['shelf_life']) * 60 * 60) + + food_form = self.get_form_class()(data=self.request.POST) + if not food_form.is_valid(): + return self.form_invalid(form) ans = super().form_valid(form) - form.instance.update() + if food.polymorphic_ctype.model == 'transformedfood': + form.instance.save(old_ingredients=old_ingredients) + else: + form.instance.save(old_allergens=old_allergens) return ans + def get_form_class(self, **kwargs): + food = Food.objects.get(pk=self.kwargs['pk']) + if food.polymorphic_ctype.model == 'basicfood': + return BasicFoodUpdateForms + else: + return TransformedFoodUpdateForms + + def get_form(self, **kwargs): + form = super().get_form(**kwargs) + if 'shelf_life' in form.initial: + hours = form.initial['shelf_life'].days * 24 + form.initial['shelf_life'].seconds // 3600 + form.initial['shelf_life'] = hours + return form + def get_success_url(self, **kwargs): self.object.refresh_from_db() - return reverse('food:food_view', kwargs={"pk": self.object.pk}) + return reverse_lazy('food:food_view', kwargs={"pk": self.object.pk}) + + +class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): + """ + A view to see a food + """ + model = Food + extra_context = {"title": _('Details of:')} + context_object_name = "food" + template_name = "food/food_detail.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"] + + fields = dict([(field, getattr(self.object, field)) for field in fields]) + if fields["is_ready"]: + fields["is_ready"] = _("Yes") + else: + fields["is_ready"] = _("No") + fields["allergens"] = ", ".join( + allergen.name for allergen in fields["allergens"].all()) + + context["fields"] = [( + Food._meta.get_field(field).verbose_name.capitalize(), + 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["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")) return context + def get(self, *args, **kwargs): + if Food.objects.filter(pk=kwargs['pk']).count() != 1: + return Http404 + model = Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model + if 'stop_redirect' in kwargs and kwargs['stop_redirect']: + return super().get(*args, **kwargs) + kwargs = {'pk': kwargs['pk']} + if model == 'basicfood': + return HttpResponseRedirect(reverse_lazy("food:basicfood_view", kwargs=kwargs)) + return HttpResponseRedirect(reverse_lazy("food:transformedfood_view", kwargs=kwargs)) -class TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): - """ - Displays ready TransformedFood - """ - model = TransformedFood - tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable] - extra_context = {"title": _("Transformed food")} - - def get_queryset(self, **kwargs): - return super().get_queryset(**kwargs).distinct() - - def get_tables(self): - tables = super().get_tables() - - tables[0].prefix = "all-" - tables[1].prefix = "open-" - tables[2].prefix = "served-" - return tables - - def get_tables_data(self): - # first table = all transformed food, second table = free, third = served - return [ - self.get_queryset().order_by("-creation_date"), - TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now()) - .filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view")) - .distinct() - .order_by("-creation_date"), - TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now()) - .filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view")) - .distinct() - .order_by("-creation_date") - ] +class BasicFoodDetailView(FoodDetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - - # We choose a club which should work - for membership in self.request.user.memberships.all(): - club_id = membership.club.id - food = TransformedFood( - name="", - owner_id=club_id, - creation_date=timezone.now(), - expiry_date=timezone.now(), - ) - if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food): - context['can_create_meal'] = True - break - - tables = context["tables"] - for name, table in zip(["table", "open", "served"], tables): - context[name] = table + fields = ['arrival_date', 'date_type'] + for field in fields: + context["fields"].append(( + BasicFood._meta.get_field(field).verbose_name.capitalize(), + getattr(self.object, field) + )) return context + + def get(self, *args, **kwargs): + if Food.objects.filter(pk=kwargs['pk']).count() == 1: + kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'basicfood') + return super().get(*args, **kwargs) + + +class TransformedFoodDetailView(FoodDetailView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["fields"].append(( + TransformedFood._meta.get_field("creation_date").verbose_name.capitalize(), + self.object.creation_date + )) + context["fields"].append(( + TransformedFood._meta.get_field("shelf_life").verbose_name.capitalize(), + pretty_duration(self.object.shelf_life) + )) + context["foods"] = self.object.ingredients.all() + context["manage_ingredients"] = True + return context + + def get(self, *args, **kwargs): + if Food.objects.filter(pk=kwargs['pk']).count() == 1: + kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood') + 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') diff --git a/apps/member/apps.py b/apps/member/apps.py index d5b1f630..84799e6a 100644 --- a/apps/member/apps.py +++ b/apps/member/apps.py @@ -6,7 +6,7 @@ from django.conf import settings from django.db.models.signals import post_save 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): @@ -17,7 +17,16 @@ class MemberConfig(AppConfig): """ Define app internal signals to interact with other apps """ + from .models import Membership, Club post_save.connect( save_user_profile, 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 + ) diff --git a/apps/member/forms.py b/apps/member/forms.py index c4940bf6..8735dc8e 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -10,6 +10,7 @@ from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.models import User from django.db import transaction from django.forms import CheckboxSelectMultiple +from phonenumber_field.formfields import PhoneNumberField from django.utils import timezone from django.utils.translation import gettext_lazy as _ 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. """ # 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")) 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 or "promotion" in self.changed_data) and "section" not in self.changed_data): 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: model = Profile diff --git a/apps/member/migrations/0014_create_bda.py b/apps/member/migrations/0014_create_bda.py new file mode 100644 index 00000000..3bebdf5d --- /dev/null +++ b/apps/member/migrations/0014_create_bda.py @@ -0,0 +1,46 @@ +from django.db import migrations + +def create_bda(apps, schema_editor): + """ + The club BDA is now pre-injected. + """ + Club = apps.get_model("member", "club") + NoteClub = apps.get_model("note", "noteclub") + Alias = apps.get_model("note", "alias") + ContentType = apps.get_model('contenttypes', 'ContentType') + polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id + + Club.objects.get_or_create( + id=10, + name="BDA", + email="bda.ensparissaclay@gmail.com", + require_memberships=True, + membership_fee_paid=750, + membership_fee_unpaid=750, + membership_duration=396, + membership_start="2024-08-01", + membership_end="2025-09-30", + ) + NoteClub.objects.get_or_create( + id=1937, + club_id=10, + polymorphic_ctype_id=polymorphic_ctype_id, + ) + Alias.objects.get_or_create( + id=1937, + note_id=1937, + name="BDA", + normalized_name="bda", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('member', '0013_auto_20240801_1436'), + ] + + operations = [ + migrations.RunPython(create_bda), + ] + diff --git a/apps/member/migrations/0015_alter_profile_promotion.py b/apps/member/migrations/0015_alter_profile_promotion.py new file mode 100644 index 00000000..f838c563 --- /dev/null +++ b/apps/member/migrations/0015_alter_profile_promotion.py @@ -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'), + ), + ] diff --git a/apps/member/models.py b/apps/member/models.py index 54637847..e08d4b59 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -417,7 +417,7 @@ class Membership(models.Model): A membership is valid if today is between the start and the end date. """ if self.date_end is not None: - return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal() + return self.date_start.toordinal() <= datetime.datetime.now().toordinal() <= self.date_end.toordinal() else: return self.date_start.toordinal() <= datetime.datetime.now().toordinal() @@ -438,8 +438,6 @@ class Membership(models.Model): ) if hasattr(self, '_force_renew_parent') and self._force_renew_parent: 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: new_membership._force_save = True new_membership.save() @@ -458,8 +456,6 @@ class Membership(models.Model): # Renew the previous membership of the parent club parent_membership = parent_membership.first() parent_membership._force_renew_parent = True - if hasattr(self, '_soge'): - parent_membership._soge = True if hasattr(self, '_force_save'): parent_membership._force_save = True parent_membership.renew() @@ -471,8 +467,6 @@ class Membership(models.Model): date_start=self.date_start, ) parent_membership._force_renew_parent = True - if hasattr(self, '_soge'): - parent_membership._soge = True if hasattr(self, '_force_save'): parent_membership._force_save = True parent_membership.save() diff --git a/apps/member/signals.py b/apps/member/signals.py index 869f9117..839f0dc9 100644 --- a/apps/member/signals.py +++ b/apps/member/signals.py @@ -1,6 +1,8 @@ # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django.conf import settings + 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.registration_valid = True 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() diff --git a/apps/member/tables.py b/apps/member/tables.py index 475c6eb2..afded61f 100644 --- a/apps/member/tables.py +++ b/apps/member/tables.py @@ -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): # If the user has the right, link the displayed user with the page of its detail. s = value.username @@ -149,6 +163,16 @@ class MembershipTable(tables.Table): + "'>" + 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: attrs = { 'class': 'table table-condensed table-striped', diff --git a/apps/member/templates/member/club_members.html b/apps/member/templates/member/club_members.html index bbeb875e..c1a1d2db 100644 --- a/apps/member/templates/member/club_members.html +++ b/apps/member/templates/member/club_members.html @@ -36,7 +36,13 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "There is no membership found with this pattern." %} {% endif %} + + {% endblock %} diff --git a/apps/member/templates/member/includes/profile_info.html b/apps/member/templates/member/includes/profile_info.html index 3a927c9f..802753cb 100644 --- a/apps/member/templates/member/includes/profile_info.html +++ b/apps/member/templates/member/includes/profile_info.html @@ -7,6 +7,19 @@
{% trans 'username'|capfirst %}
{{ user_object.username }}
+ {% if family_app_installed %} +
{% trans 'family'|capfirst %}
+
+ {% if families %} + {% for fam in families %} + {{ fam.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% else %} + Aucune + {% endif %} +
+ {% endif %} + {% if user_object.pk == user.pk %}
{% trans 'password'|capfirst %}
diff --git a/apps/member/templates/member/profile_update.html b/apps/member/templates/member/profile_update.html index 2f018381..36029cb3 100644 --- a/apps/member/templates/member/profile_update.html +++ b/apps/member/templates/member/profile_update.html @@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {{ title }}
-
+ {% csrf_token %} {{ form | crispy }} {{ profile_form | crispy }} @@ -20,4 +20,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
+{% endblock %} + +{% block extrajavascript %} + + {% endblock %} \ No newline at end of file diff --git a/apps/member/tests/test_login.py b/apps/member/tests/test_login.py index b8873a14..ce5de1cf 100644 --- a/apps/member/tests/test_login.py +++ b/apps/member/tests/test_login.py @@ -44,7 +44,7 @@ class TemplateLoggedInTests(TestCase): self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302) def test_logout(self): - response = self.client.get(reverse("logout")) + response = self.client.post(reverse("logout")) self.assertEqual(response.status_code, 200) def test_admin_index(self): diff --git a/apps/member/views.py b/apps/member/views.py index 19f9b46f..84275db1 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -17,6 +17,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, UpdateView, TemplateView from django.views.generic.edit import FormMixin from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView +from django_tables2.export.views import ExportMixin from rest_framework.authtoken.models import Token from api.viewsets import is_regex 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.models import Role from permission.views import ProtectQuerysetMixin, ProtectedCreateView +from family.models import Family from django import forms from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \ @@ -48,6 +50,15 @@ class CustomLoginView(LoginView): self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank return super().form_valid(form) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user_agent = self.request.META.get('HTTP_USER_AGENT', '').lower() + + context['display_appstore_badge'] = 'iphone' in user_agent or 'android' not in user_agent + context['display_playstore_badge'] = 'android' in user_agent or 'iphone' not in user_agent + + return context + class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): """ @@ -206,6 +217,10 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): modified_note.is_active = True context["can_unlock_note"] = not user.note.is_active and PermissionBackend\ .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 @@ -945,11 +960,12 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): 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 table_class = MembershipTable template_name = "member/club_members.html" extra_context = {"title": _("Members of the club")} + export_formats = ["csv"] def get_queryset(self, **kwargs): qs = super().get_queryset().filter(club_id=self.kwargs["pk"]) @@ -981,6 +997,14 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV 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): context = super().get_context_data(**kwargs) club = Club.objects.filter( diff --git a/apps/note/api/urls.py b/apps/note/api/urls.py index 67d7371e..c50e68e4 100644 --- a/apps/note/api/urls.py +++ b/apps/note/api/urls.py @@ -13,7 +13,7 @@ def register_note_urls(router, path): router.register(path + '/note', NotePolymorphicViewSet) router.register(path + '/alias', AliasViewSet) router.register(path + '/trust', TrustViewSet) - router.register(path + '/consumer', ConsumerViewSet) + router.register(path + '/consumer', ConsumerViewSet, basename='alias2') router.register(path + '/transaction/category', TemplateCategoryViewSet) router.register(path + '/transaction/transaction', TransactionViewSet) diff --git a/apps/note/static/note/js/consos.js b/apps/note/static/note/js/consos.js index d08d93bd..99bdf610 100644 --- a/apps/note/static/note/js/consos.js +++ b/apps/note/static/note/js/consos.js @@ -228,7 +228,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' + 'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000) } - if (source.membership && source.membership.date_end < new Date().toISOString()) { + if (source.membership && source.membership.date_end <= new Date().toISOString()) { addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]), 'danger', 30000) } diff --git a/apps/note/static/note/js/transfer.js b/apps/note/static/note/js/transfer.js index 509d9b48..1c8797f4 100644 --- a/apps/note/static/note/js/transfer.js +++ b/apps/note/static/note/js/transfer.js @@ -66,6 +66,8 @@ $(document).ready(function () { arr.push(last) last.quantity = 1 + + if (last.note.club) { $('#last_name').val(last.note.name) @@ -111,7 +113,8 @@ $(document).ready(function () { dest.removeClass('d-none') $('#dest_note_list').removeClass('d-none') $('#debit_type').addClass('d-none') - + $('#reason').val('') + $('#source_note_label').text(select_emitters_label) $('#dest_note_label').text(select_receveirs_label) @@ -134,6 +137,7 @@ $(document).ready(function () { dest.val('') dest.tooltip('hide') $('#debit_type').addClass('d-none') + $('#reason').val('Rechargement note') $('#source_note_label').text(transfer_type_label) $('#dest_note_label').text(select_receveir_label) @@ -162,6 +166,7 @@ $(document).ready(function () { dest.addClass('d-none') dest.tooltip('hide') $('#debit_type').removeClass('d-none') + $('#reason').val('') $('#source_note_label').text(select_emitter_label) $('#dest_note_label').text(transfer_type_label) @@ -305,10 +310,10 @@ $('#btn_transfer').click(function () { destination: dest.note.id, destination_alias: dest.name }).done(function () { - if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) { + if (source.note.membership && source.note.membership.date_end <= new Date().toISOString()) { addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source.name]), 'danger', 30000) } - if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) { + if (dest.note.membership && dest.note.membership.date_end <= new Date().toISOString()) { addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000) } @@ -409,7 +414,7 @@ $('#btn_transfer').click(function () { bank: $('#bank').val() }).done(function () { addMsg(gettext('Credit/debit succeed!'), 'success', 10000) - if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) } + if (user_note.membership && user_note.membership.date_end <= new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) } reset() }).fail(function (err) { const errObj = JSON.parse(err.responseText) diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json index 347e4885..dcce9b02 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -927,7 +927,7 @@ "note", "transactiontemplate" ], - "query": "{\"destination\": [\"club\", \"note\"]}", + "query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]", "type": "view", "mask": 2, "field": "", @@ -943,7 +943,7 @@ "note", "transactiontemplate" ], - "query": "{\"destination\": [\"club\", \"note\"]}", + "query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]", "type": "add", "mask": 3, "field": "", @@ -959,7 +959,7 @@ "note", "transactiontemplate" ], - "query": "{\"destination\": [\"club\", \"note\"]}", + "query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]", "type": "change", "mask": 3, "field": "", @@ -1391,12 +1391,12 @@ "wei", "weiregistration" ], - "query": "{\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}", + "query": "[\"AND\", {\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}, {\"deposit_type\": \"note\"}]", "type": "change", "mask": 2, - "field": "caution_check", + "field": "deposit_given", "permanent": false, - "description": "Dire si un chèque de caution est donné pour une inscription WEI" + "description": "Autoriser une transaction de caution WEI" } }, { @@ -1695,7 +1695,7 @@ "wei", "weimembership" ], - "query": "[\"AND\", {\"club\": [\"club\"], \"club__weiclub__membership_end__gte\": [\"today\"]}, [\"OR\", {\"registration__soge_credit\": true}, {\"user__note__balance__gte\": {\"F\": [\"F\", \"fee\"]}}]]", + "query": "{\"club\": [\"club\"]}", "type": "add", "mask": 2, "field": "", @@ -3307,451 +3307,199 @@ } }, { - "model": "permission.permission", - "pk": 211, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tout les plats" - } + "model": "permission.permission", + "pk": 211, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir n'importe quel QR-code" + } }, { - "model": "permission.permission", - "pk": 212, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"owner\": [\"club\"]}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tout les plats de son club" - } + "model": "permission.permission", + "pk": 212, + "fields": { + "model": [ + "food", + "allergen" + ], + "query": "{}", + "type": "view", + "mask": 1, + "permanent": false, + "description": "Voir n'importe quel allergène" + } }, { - "model": "permission.permission", - "pk": 213, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_ready\": true, \"is_active\": true, \"was_eaten\": false}", - "type": "view", - "mask": 1, - "field": "", - "permanent": false, - "description": "Voir les plats préparés actifs servis" - } + "model": "permission.permission", + "pk": 213, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir n'importe quelle bouffe" + } }, { - "model": "permission.permission", - "pk": 214, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Initialiser un QR code de traçabilité" - } + "model": "permission.permission", + "pk": 214, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter n'importe quel QR-code" + } }, { - "model": "permission.permission", - "pk": 215, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"owner\": [\"club\"]}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un nouvel ingrédient pour son club" - } + "model": "permission.permission", + "pk": 215, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter n'importe quelle bouffe" + } }, { - "model": "permission.permission", - "pk": 216, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un nouvel ingrédient" - } + "model": "permission.permission", + "pk": 216, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{}", + "type": "change", + "mask": 2, + "permanent": false, + "description": "Modifier n'importe quelle bouffe" + } }, { - "model": "permission.permission", - "pk": 217, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir toute la bouffe" - } + "model": "permission.permission", + "pk": 217, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{\"food_container__owner\": [\"club\"]}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir un QR-code lié à son club" + } }, { - "model": "permission.permission", - "pk": 218, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir toute la bouffe active" - } + "model": "permission.permission", + "pk": 218, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"owner\": [\"club\"]}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir la bouffe de son club" + } }, { - "model": "permission.permission", - "pk": 219, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir la bouffe active de son club" - } + "model": "permission.permission", + "pk": 219, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{\"food_container__owner\": [\"club\"]}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter un QR-code pour son club" + } }, { - "model": "permission.permission", - "pk": 220, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{}", - "type": "change", - "mask": 3, - "field": "", - "permanent": false, - "description": "Modifier de la bouffe" - } + "model": "permission.permission", + "pk": 220, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"owner\": [\"club\"]}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter de la bouffe appartenant à son club" + } }, { - "model": "permission.permission", - "pk": 221, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true, \"was_eaten\": false}", - "type": "change", - "mask": 3, - "field": "allergens", - "permanent": false, - "description": "Modifier les allergènes de la bouffe existante" - } + "model": "permission.permission", + "pk": 221, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"owner\": [\"club\"]}", + "type": "change", + "mask": 2, + "permanent": false, + "description": "Modifier la bouffe appartenant à son club" + } }, { - "model": "permission.permission", - "pk": 222, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true, \"was_eaten\": false, \"owner\": [\"club\"]}", - "type": "change", - "mask": 3, - "field": "allergens", - "permanent": false, - "description": "Modifier les allergènes de la bouffe appartenant à son club" - } + "model": "permission.permission", + "pk": 222, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"end_of_life\": \"\"}", + "type": "view", + "mask": 1, + "permanent": false, + "description": "Voir la bouffe servie" + } }, { "model": "permission.permission", "pk": 223, "fields": { "model": [ - "food", - "transformedfood" + "note", + "templatecategory" ], - "query": "{}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un plat" - } - }, - { - "model": "permission.permission", - "pk": 224, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"owner\": [\"club\"]}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un plat pour son club" - } - }, - { - "model": "permission.permission", - "pk": 225, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{}", - "type": "change", - "mask": 3, - "field": "", - "permanent": false, - "description": "Modifier tout les plats" - } - }, - { - "model": "permission.permission", - "pk": 226, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true}", - "type": "change", - "mask": 3, - "field": "was_eaten", - "permanent": false, - "description": "Indiquer si un plat a été mangé" - } - }, - { - "model": "permission.permission", - "pk": 227, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", - "type": "change", - "mask": 3, - "field": "is_ready", - "permanent": false, - "description": "Indiquer si un plat de son club est prêt" - } - }, - { - "model": "permission.permission", - "pk": 228, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true}", - "type": "change", - "mask": 3, - "field": "is_active", - "permanent": false, - "description": "Archiver un plat" - } - }, - { - "model": "permission.permission", - "pk": 229, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true}", - "type": "change", - "mask": 3, - "field": "is_active", - "permanent": false, - "description": "Archiver de la bouffe" - } - }, - { - "model": "permission.permission", - "pk": 230, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true}", + "query": "{\"name\": \"Clubs\"}", "type": "view", - "mask": 3, + "mask": 2, "field": "", "permanent": false, - "description": "Voir tout les plats actifs" - } - }, - { - "model": "permission.permission", - "pk": 231, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tous les QR codes" - } - }, - { - "model": "permission.permission", - "pk": 232, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{\"food_container__is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tous les QR codes actifs" - } - }, - { - "model": "permission.permission", - "pk": 233, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{\"food_container__owner\": [\"club\"], \"food_container__is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tous les QR codes actifs de son club" - } - }, - { - "model": "permission.permission", - "pk" : 234, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"owner\": [\"club\"], \"is_active\": true}", - "type": "change", - "mask": 3, - "field": "ingredients", - "permanent": false, - "description": "Changer les ingrédients d'un plat actif de son club" - } - }, - { - "model": "permission.permission", - "pk": 235, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir bouffe" - } - }, - { - "model": "permission.permission", - "pk": 236, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{\"is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir bouffe active" - } - }, - { - "model": "permission.permission", - "pk": 237, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir bouffe active de son club" - } - }, - { - "model": "permission.permission", - "pk": 238, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{}", - "type": "change", - "mask": 3, - "field": "", - "permanent": false, - "description": "Modifier bouffe" + "description": "Voir la catégorie de bouton Clubs" } }, { @@ -4266,6 +4014,726 @@ "description": "Créer une transaction de ou vers la note d'un club tant que la source reste au dessus de -50 €" } }, + { + "model": "permission.permission", + "pk": 271, + "fields": { + "model": [ + "wei", + "bus" + ], + "query": "{\"wei\": [\"club\"]}", + "type": "change", + "mask": 3, + "field": "", + "permanent": false, + "description": "Modifier n'importe quel bus du wei" + } + }, + { + "model": "permission.permission", + "pk": 272, + "fields": { + "model": [ + "wei", + "bus" + ], + "query": "{\"wei\": [\"club\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir tous les bus du wei" + } + }, + { + "model": "permission.permission", + "pk": 273, + "fields": { + "model": [ + "wei", + "busteam" + ], + "query": "{\"bus__wei\": [\"club\"], \"bus__wei__membership_end__gte\": [\"today\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir toutes les équipes WEI" + } + }, + { + "model": "permission.permission", + "pk": 274, + "fields": { + "model": [ + "member", + "club" + ], + "query": "{\"bus__wei\": [\"club\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir les informations de clubs des bus" + } + }, + { + "model": "permission.permission", + "pk": 275, + "fields": { + "model": [ + "member", + "club" + ], + "query": "{\"bus__wei\": [\"club\"]}", + "type": "change", + "mask": 3, + "field": "", + "permanent": false, + "description": "Modifier les clubs des bus" + } + }, + { + "model": "permission.permission", + "pk": 276, + "fields": { + "model": [ + "member", + "membership" + ], + "query": "{\"club__bus__wei\": [\"club\"]}", + "type": "add", + "mask": 3, + "field": "", + "permanent": false, + "description": "Ajouter un⋅e membre à un club de bus" + } + }, + { + "model": "permission.permission", + "pk": 277, + "fields": { + "model": [ + "member", + "membership" + ], + "query": "{\"club__bus__wei\": [\"club\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir les adhérents d'un club de bus" + } + }, + { + "model": "permission.permission", + "pk": 278, + "fields": { + "model": [ + "member", + "membership" + ], + "query": "{\"club__bus__wei\": [\"club\"]}", + "type": "change", + "mask": 3, + "field": "", + "permanent": false, + "description": "Modifier l'adhésion d'un club de bus" + } + }, + { + "model": "permission.permission", + "pk": 279, + "fields": { + "model": [ + "note", + "note" + ], + "query": "{\"noteclub__club__bus__wei\": [\"club\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir la note d'un club de bus" + } + }, + { + "model": "permission.permission", + "pk": 280, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "[\"OR\", {\"source__noteclub__club__bus__wei\": [\"club\"]}, {\"destination__noteclub__club__bus__wei\": [\"club\"]}]", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir les transactions d'un club de bus" + } + }, + { + "model": "permission.permission", + "pk": 281, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "[\"AND\", [\"OR\", {\"source__noteclub__club__bus__wei\": [\"club\"]}, {\"destination__noteclub__club__bus__wei\": [\"club\"]}], [\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 2000]}}, {\"valid\": false}]]", + "type": "add", + "mask": 3, + "field": "", + "permanent": false, + "description": "Créer une transaction d'un club de bus tant que la source reste au dessus de -20 €" + } + }, + { + "model": "permission.permission", + "pk": 282, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "[\"AND\", [\"OR\", {\"source__noteclub__club\": [\"club\"]}, {\"destination__noteclub__club\": [\"club\"]}], [\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 2000]}}, {\"valid\": false}]]", + "type": "add", + "mask": 3, + "field": "", + "permanent": false, + "description": "Créer une transaction d'un WEI tant que la source reste au dessus de -20 €" + } + }, + { + "model": "permission.permission", + "pk": 283, + "fields": { + "model": [ + "auth", + "user" + ], + "query": "{\"memberships__club__name\": \"Kfet\", \"memberships__roles__name\": \"Adh\u00e9rent\u22c5e Kfet\", \"memberships__date_start__lte\": [\"today\"], \"memberships__date_end__gte\": [\"today\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir n'importe quel⋅le utilisateur⋅rice qui est adhérent⋅e Kfet" + } + }, + { + "model": "permission.permission", + "pk": 284, + "fields": { + "model": [ + "member", + "club" + ], + "query": "{\"bus\": [\"membership\", \"weimembership\", \"bus\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir les informations de club de son bus" + } + }, + { + "model": "permission.permission", + "pk": 285, + "fields": { + "model": [ + "member", + "club" + ], + "query": "{\"bus\": [\"membership\", \"weimembership\", \"bus\"]}", + "type": "change", + "mask": 3, + "field": "", + "permanent": false, + "description": "Modifier le club de son bus" + } + }, + { + "model": "permission.permission", + "pk": 286, + "fields": { + "model": [ + "member", + "membership" + ], + "query": "{\"club__bus\": [\"membership\", \"weimembership\", \"bus\"]}", + "type": "add", + "mask": 3, + "field": "", + "permanent": false, + "description": "Ajouter un⋅e membre au club de son bus" + } + }, + { + "model": "permission.permission", + "pk": 287, + "fields": { + "model": [ + "member", + "membership" + ], + "query": "{\"club__bus\": [\"membership\", \"weimembership\", \"bus\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir les adhérents du club de son bus" + } + }, + { + "model": "permission.permission", + "pk": 288, + "fields": { + "model": [ + "member", + "membership" + ], + "query": "{\"club__bus\": [\"membership\", \"weimembership\", \"bus\"]}", + "type": "change", + "mask": 3, + "field": "", + "permanent": false, + "description": "Modifier l'adhésion au club de son bus" + } + }, + { + "model": "permission.permission", + "pk": 289, + "fields": { + "model": [ + "note", + "note" + ], + "query": "{\"noteclub__club__bus\": [\"membership\", \"weimembership\", \"bus\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir la note du club de son bus" + } + }, + { + "model": "permission.permission", + "pk": 290, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "[\"OR\", {\"source__noteclub__club__bus\": [\"membership\", \"weimembership\", \"bus\"]}, {\"destination__noteclub__club__bus\": [\"membership\", \"weimembership\", \"bus\"]}]", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir les transactions du club de son bus" + } + }, + { + "model": "permission.permission", + "pk": 291, + "fields": { + "model": [ + "wei", + "bus" + ], + "query": "{\"pk\": [\"membership\", \"weimembership\", \"bus\", \"pk\"], \"wei__date_end__gte\": [\"today\"]}", + "type": "view", + "mask": 3, + "field": "", + "permanent": false, + "description": "Voir mon bus" + } + }, + { + "model": "permission.permission", + "pk": 292, + "fields": { + "model": [ + "member", + "membership" + ], + "query": "{\"club__pk__lte\": 2}", + "type": "add", + "mask": 3, + "field": "", + "permanent": false, + "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" + } + }, { "model": "permission.role", "pk": 1, @@ -4318,9 +4786,13 @@ 206, 248, 249, - 255, - 256, - 257 + 255, + 256, + 257, + 311, + 316, + 319, + 323 ] } }, @@ -4359,7 +4831,9 @@ 158, 159, 160, - 213 + 212, + 222, + 297 ] } }, @@ -4400,19 +4874,18 @@ 50, 141, 169, - 212, - 214, - 215, + 217, + 218, 219, - 222, - 224, - 227, - 233, - 234, - 237, - 247, - 258, - 259 + 220, + 221, + 247, + 258, + 259, + 260, + 263, + 265, + 330 ] } }, @@ -4424,8 +4897,7 @@ "name": "Pr\u00e9sident\u22c5e de club", "permissions": [ 62, - 142, - 135 + 142 ] } }, @@ -4440,7 +4912,6 @@ 19, 20, 21, - 27, 59, 60, 61, @@ -4451,6 +4922,7 @@ 182, 184, 185, + 223, 239, 240, 241 @@ -4551,7 +5023,10 @@ 176, 177, 178, - 183 + 183, + 294, + 295, + 296 ] } }, @@ -4590,21 +5065,7 @@ 166, 167, 168, - 182, - 212, - 214, - 215, - 218, - 221, - 224, - 226, - 227, - 228, - 229, - 230, - 232, - 234, - 236 + 182 ] } }, @@ -4644,6 +5105,8 @@ "name": "GC WEI", "permissions": [ 22, + 49, + 62, 70, 72, 76, @@ -4668,7 +5131,23 @@ 112, 113, 128, - 130 + 130, + 142, + 269, + 271, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 281, + 282, + 283, + 292 ] } }, @@ -4680,14 +5159,22 @@ "name": "Chef\u22c5fe de bus", "permissions": [ 22, - 84, 115, 117, 118, 119, 120, 121, - 122 + 122, + 284, + 285, + 286, + 287, + 289, + 290, + 291, + 293, + 298 ] } }, @@ -4699,7 +5186,6 @@ "name": "Chef\u22c5fe d'\u00e9quipe", "permissions": [ 22, - 84, 116, 123, 124, @@ -4714,20 +5200,7 @@ "for_club": null, "name": "\u00c9lectron libre", "permissions": [ - 22, - 84 - ] - } - }, - { - "model": "permission.role", - "pk": 16, - "fields": { - "for_club": null, - "name": "\u00c9lectron libre (avec perm)", - "permissions": [ - 22, - 84 + 22 ] } }, @@ -4761,6 +5234,7 @@ "permissions": [ 37, 41, + 42, 53, 54, 55, @@ -4815,7 +5289,17 @@ 178, 197, 211, +<<<<<<< HEAD 244 +======= + 212, + 213, + 214, + 215, + 216, + 311, + 319 +>>>>>>> main ] } }, @@ -4827,8 +5311,8 @@ "name": "GC anti-VSS", "permissions": [ 42, - 135, - 150, + 135, + 150, 163, 164 ] @@ -4843,18 +5327,182 @@ "permissions": [ 137, 211, + 212, + 213, 214, - 216, - 217, - 220, - 223, - 225, - 231, - 235, - 238 + 215, + 216 ] } - }, + }, + { + "model": "permission.role", + "pk": 23, + "fields": { + "for_club": 2, + "name": "Darbonne", + "permissions": [ + 30, + 31, + 32 + ] + } + }, + { + "model": "permission.role", + "pk": 24, + "fields": { + "for_club": null, + "name": "Staffeur⋅euse (S&L,Respo Tech,...)", + "permissions": [] + } + }, + { + "model": "permission.role", + "pk": 25, + "fields": { + "for_club": null, + "name": "Référent⋅e Bus", + "permissions": [ + 22, + 115, + 117, + 118, + 119, + 120, + 121, + 122, + 284, + 285, + 286, + 287, + 289, + 290, + 291, + 293, + 298 + ] + } + }, + { + "model": "permission.role", + "pk": 28, + "fields": { + "for_club": 10, + "name": "Trésorièr⸱e BDA", + "permissions": [ + 55, + 56, + 57, + 58, + 135, + 143, + 176, + 177, + 178, + 243, + 260, + 261, + 262, + 263, + 264, + 265, + 266, + 267, + 268, + 269 + ] + } + }, + { + "model": "permission.role", + "pk": 30, + "fields": { + "for_club": 10, + "name": "Respo sorties", + "permissions": [ + 49, + 62, + 141, + 241, + 242, + 243 + ] + } + }, + { + "model": "permission.role", + "pk": 31, + "fields": { + "for_club": 1, + "name": "Respo comm", + "permissions": [ + 135, + 244 + ] + } + }, + { + "model": "permission.role", + "pk": 32, + "fields": { + "for_club": 10, + "name": "Respo comm Art", + "permissions": [ + 135, + 245 + ] + } + }, + { + "model": "permission.role", + "pk": 33, + "fields": { + "for_club": 10, + "name": "Respo Jam", + "permissions": [ + 247, + 250, + 251, + 252, + 253, + 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", "pk": 12, @@ -4875,11 +5523,6 @@ "pk": 15, "fields": {} }, - { - "model": "wei.weirole", - "pk": 16, - "fields": {} - }, { "model": "wei.weirole", "pk": 17, @@ -4889,5 +5532,15 @@ "model": "wei.weirole", "pk": 18, "fields": {} + }, + { + "model": "wei.weirole", + "pk": 24, + "fields": {} + }, + { + "model": "wei.weirole", + "pk": 25, + "fields": {} } ] diff --git a/apps/permission/scopes.py b/apps/permission/scopes.py index 6ee5818f..2842546f 100644 --- a/apps/permission/scopes.py +++ b/apps/permission/scopes.py @@ -1,8 +1,10 @@ # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later + from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.scopes import BaseScopes from member.models import Club +from note.models import Alias from note_kfet.middlewares import get_current_request from .backends import PermissionBackend @@ -16,26 +18,58 @@ class PermissionScopes(BaseScopes): and can be useful to make queries through the API with limited privileges. """ - def get_all_scopes(self): - return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})" - for p in Permission.objects.all() for club in Club.objects.all()} + def get_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})" + for p in Permission.objects.all() for club in Club.objects.all()} + scopes['openid'] = "OpenID Connect" + return scopes def get_available_scopes(self, application=None, request=None, *args, **kwargs): if not application: return [] - return [f"{p.id}_{p.membership.club.id}" - for t in Permission.PERMISSION_TYPES - for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])] + scopes = [f"{p.id}_{p.membership.club.id}" + for t in Permission.PERMISSION_TYPES + for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])] + scopes.append('openid') + return scopes def get_default_scopes(self, application=None, request=None, *args, **kwargs): if not application: return [] - return [f"{p.id}_{p.membership.club.id}" - for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')] + scopes = [f"{p.id}_{p.membership.club.id}" + for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')] + scopes.append('openid') + return scopes class PermissionOAuth2Validator(OAuth2Validator): - oidc_claim_scope = None # fix breaking change of django-oauth-toolkit 2.0.0 + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"name": 'openid', + "normalized_name": 'openid', + "email": 'openid', + }) + + def get_additional_claims(self, request): + return { + "name": request.user.username, + "normalized_name": Alias.normalize(request.user.username), + "email": request.user.email, + } + + def get_discovery_claims(self, request): + claims = super().get_discovery_claims(self) + return claims + ["name", "normalized_name", "email"] def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): """ @@ -54,6 +88,8 @@ class PermissionOAuth2Validator(OAuth2Validator): if scope in scopes: valid_scopes.add(scope) - request.scopes = valid_scopes + if 'openid' in scopes: + valid_scopes.add('openid') + request.scopes = valid_scopes return valid_scopes diff --git a/apps/permission/signals.py b/apps/permission/signals.py index b2394c6f..af5ab4ce 100644 --- a/apps/permission/signals.py +++ b/apps/permission/signals.py @@ -13,12 +13,14 @@ EXCLUDED = [ 'cas_server.serviceticket', 'cas_server.user', 'cas_server.userattributes', + 'constance.constance', 'contenttypes.contenttype', 'logs.changelog', 'migrations.migration', 'oauth2_provider.accesstoken', 'oauth2_provider.grant', 'oauth2_provider.refreshtoken', + 'oauth2_provider.idtoken', 'sessions.session', ] diff --git a/apps/permission/tests/test_permission_denied.py b/apps/permission/tests/test_permission_denied.py index 792bd1de..c2f3ad3a 100644 --- a/apps/permission/tests/test_permission_denied.py +++ b/apps/permission/tests/test_permission_denied.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.utils.crypto import get_random_string from activity.models import Activity from member.models import Club, Membership -from note.models import NoteUser +from note.models import NoteUser, NoteClub from wei.models import WEIClub, Bus, WEIRegistration @@ -122,10 +122,13 @@ class TestPermissionDenied(TestCase): def test_validate_weiregistration(self): wei = WEIClub.objects.create( + name="WEI Test", membership_start=date.today(), date_start=date.today() + timedelta(days=1), date_end=date.today() + timedelta(days=1), + parent_club=Club.objects.get(name="Kfet"), ) + NoteClub.objects.create(club=wei) registration = WEIRegistration.objects.create(wei=wei, user=self.user, birth_date="2000-01-01") response = self.client.get(reverse("wei:validate_registration", kwargs=dict(pk=registration.pk))) self.assertEqual(response.status_code, 403) diff --git a/apps/permission/views.py b/apps/permission/views.py index e7de920e..30b13316 100644 --- a/apps/permission/views.py +++ b/apps/permission/views.py @@ -164,14 +164,24 @@ class ScopesView(LoginRequiredMixin, TemplateView): from oauth2_provider.models import Application from .scopes import PermissionScopes - scopes = PermissionScopes() + oidc = False context["scopes"] = {} - all_scopes = scopes.get_all_scopes() for app in Application.objects.filter(user=self.request.user).all(): - available_scopes = scopes.get_available_scopes(app) + available_scopes = PermissionScopes().get_available_scopes(app) 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) + 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: context["scopes"][app][k] = v diff --git a/apps/treasury/migrations/0011_sogecredit_valid.py b/apps/treasury/migrations/0011_sogecredit_valid.py new file mode 100644 index 00000000..44ef6c90 --- /dev/null +++ b/apps/treasury/migrations/0011_sogecredit_valid.py @@ -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'), + ), + ] diff --git a/apps/treasury/models.py b/apps/treasury/models.py index 7695ac02..227773b7 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -308,6 +308,12 @@ class SogeCredit(models.Model): null=True, ) + valid = models.BooleanField( + default=False, + verbose_name=_("Valid"), + blank=True, + ) + class Meta: verbose_name = _("Credit from the Société générale") verbose_name_plural = _("Credits from the Société générale") @@ -332,7 +338,7 @@ class SogeCredit(models.Model): last_name=self.user.last_name, first_name=self.user.first_name, bank="Société générale", - valid=False, + valid=True, ) credit_transaction._force_save = True credit_transaction.save() @@ -346,20 +352,18 @@ class SogeCredit(models.Model): return super().save(*args, **kwargs) @property - def valid(self): + def valid_legacy(self): return self.credit_transaction and self.credit_transaction.valid @property def amount(self): - if self.valid: + if self.valid_legacy: return self.credit_transaction.total - amount = sum(transaction.total for transaction in self.transactions.all()) - if 'wei' in settings.INSTALLED_APPS: - from wei.models import WEIMembership - if not WEIMembership.objects\ - .filter(club__weiclub__year=self.credit_transaction.created_at.year, user=self.user).exists(): - # 80 € for people that don't go to WEI - amount += 8000 + amount = 0 + transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False) + amount += sum(max(transaction.total - transaction.membership.club.weiclub.fee_soge_credit, 0) for transaction in transactions_wei) + transactions_not_wei = self.transactions.filter(membership__club__weiclub__isnull=True) + amount += sum(transaction.total for transaction in transactions_not_wei) return amount def update_transactions(self): @@ -399,7 +403,7 @@ class SogeCredit(models.Model): self.transactions.add(m.transaction) for tr in self.transactions.all(): - tr.valid = False + tr.valid = True tr.save() def invalidate(self): @@ -424,6 +428,7 @@ class SogeCredit(models.Model): self.invalidate() # Refresh credit amount self.save() + self.valid = True self.credit_transaction.valid = True self.credit_transaction._force_save = True self.credit_transaction.save() @@ -441,7 +446,7 @@ class SogeCredit(models.Model): 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: 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.")) diff --git a/apps/treasury/static/img/Diolistos_bg.jpg b/apps/treasury/static/img/Diolistos_bg.jpg index e9453dbd..3bbc7b2e 100755 Binary files a/apps/treasury/static/img/Diolistos_bg.jpg and b/apps/treasury/static/img/Diolistos_bg.jpg differ diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py index cf17a2c8..2931e800 100644 --- a/apps/treasury/tables.py +++ b/apps/treasury/tables.py @@ -56,6 +56,7 @@ class InvoiceTable(tables.Table): model = Invoice template_name = 'django_tables2/bootstrap4.html' fields = ('id', 'name', 'object', 'acquitted', 'invoice',) + order_by = ('-id',) class RemittanceTable(tables.Table): diff --git a/apps/treasury/views.py b/apps/treasury/views.py index eab144c3..3d4b4397 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -168,7 +168,7 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): """ - Delete a non-validated WEI registration + Delete a non-locked Invoice """ model = Invoice extra_context = {"title": _("Delete invoice")} @@ -417,7 +417,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi ) if "valid" not in self.request.GET or not self.request.GET["valid"]: - qs = qs.filter(credit_transaction__valid=False) + qs = qs.filter(valid=False) return qs diff --git a/apps/wei/api/views.py b/apps/wei/api/views.py index f6eecbfc..99f36d1c 100644 --- a/apps/wei/api/views.py +++ b/apps/wei/api/views.py @@ -77,7 +77,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet): filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] 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', - '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', 'emergency_contact_phone', ] search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', diff --git a/apps/wei/forms/__init__.py b/apps/wei/forms/__init__.py index e1a09c3a..1cb9f283 100644 --- a/apps/wei/forms/__init__.py +++ b/apps/wei/forms/__init__.py @@ -1,7 +1,8 @@ # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, WEIMembershipForm, BusForm, BusTeamForm +from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, \ + WEIMembershipForm, BusForm, BusTeamForm from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey __all__ = [ diff --git a/apps/wei/forms/registration.py b/apps/wei/forms/registration.py index 38568b93..31f42f89 100644 --- a/apps/wei/forms/registration.py +++ b/apps/wei/forms/registration.py @@ -5,7 +5,7 @@ from bootstrap_datepicker_plus.widgets import DatePickerInput from django import forms from django.contrib.auth.models import User 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 note.models import NoteSpecial, NoteUser from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget @@ -24,6 +24,8 @@ class WEIForm(forms.ModelForm): "membership_end": DatePickerInput(), "date_start": DatePickerInput(), "date_end": DatePickerInput(), + "deposit_amount": AmountInput(), + "fee_soge_credit": AmountInput(), } @@ -39,7 +41,11 @@ class WEIRegistrationForm(forms.ModelForm): class Meta: model = WEIRegistration - exclude = ('wei', 'clothing_cut') + fields = [ + 'user', 'soge_credit', 'birth_date', 'gender', 'clothing_size', + 'health_issues', 'emergency_contact_name', 'emergency_contact_phone', + 'first_year', 'information_json', 'deposit_given', 'deposit_type' + ] widgets = { "user": Autocomplete( User, @@ -49,15 +55,21 @@ class WEIRegistrationForm(forms.ModelForm): 'placeholder': 'Nom ...', }, ), - "birth_date": DatePickerInput(options={'minDate': '1900-01-01', - 'maxDate': '2100-01-01'}), + "birth_date": DatePickerInput(options={ + 'minDate': '1900-01-01', + 'maxDate': '2100-01-01' + }), + "deposit_given": forms.CheckboxInput( + attrs={'class': 'form-check-input'}, + ), + "deposit_type": forms.RadioSelect(), } class WEIChooseBusForm(forms.Form): bus = forms.ModelMultipleChoiceField( 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," + " in particular if you are a free eletron."), widget=CheckboxSelectMultiple(), @@ -72,22 +84,17 @@ class WEIChooseBusForm(forms.Form): ) roles = forms.ModelMultipleChoiceField( - queryset=WEIRole.objects.filter(~Q(name="1A")), + queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")), label=_("WEI Roles"), 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(), ) class WEIMembershipForm(forms.ModelForm): - caution_check = forms.BooleanField( - required=False, - label=_("Caution check given"), - ) - roles = forms.ModelMultipleChoiceField( - queryset=WEIRole.objects, + queryset=WEIRole.objects.filter(~Q(name="GC WEI")), label=_("WEI Roles"), widget=CheckboxSelectMultiple(), ) @@ -121,6 +128,19 @@ class WEIMembershipForm(forms.ModelForm): 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): cleaned_data = super().clean() if 'team' in cleaned_data and cleaned_data["team"] is not None \ @@ -132,21 +152,8 @@ class WEIMembershipForm(forms.ModelForm): model = WEIMembership fields = ('roles', 'bus', 'team',) widgets = { - "bus": Autocomplete( - Bus, - attrs={ - 'api_url': '/api/wei/bus/', - 'placeholder': 'Bus ...', - } - ), - "team": Autocomplete( - BusTeam, - attrs={ - 'api_url': '/api/wei/team/', - 'placeholder': 'Équipe ...', - }, - resetable=True, - ), + "bus": RadioSelect(), + "team": RadioSelect(), } @@ -154,7 +161,7 @@ class WEIMembership1AForm(WEIMembershipForm): """ Used to confirm registrations of first year members without choosing a bus now. """ - caution_check = None + deposit_given = None roles = None def clean(self): diff --git a/apps/wei/forms/surveys/__init__.py b/apps/wei/forms/surveys/__init__.py index 0a33bc16..ef692d25 100644 --- a/apps/wei/forms/surveys/__init__.py +++ b/apps/wei/forms/surveys/__init__.py @@ -2,11 +2,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm -from .wei2024 import WEISurvey2024 +from .wei2025 import WEISurvey2025 __all__ = [ 'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey', ] -CurrentSurvey = WEISurvey2024 +CurrentSurvey = WEISurvey2025 diff --git a/apps/wei/forms/surveys/base.py b/apps/wei/forms/surveys/base.py index 3f3bff3b..c2fde39d 100644 --- a/apps/wei/forms/surveys/base.py +++ b/apps/wei/forms/surveys/base.py @@ -121,6 +121,13 @@ class WEISurveyAlgorithm: """ raise NotImplementedError + @classmethod + def get_bus_information_form(cls): + """ + The class of the form to update the bus information. + """ + raise NotImplementedError + class WEISurvey: """ diff --git a/apps/wei/forms/surveys/wei2025.py b/apps/wei/forms/surveys/wei2025.py new file mode 100644 index 00000000..758776b8 --- /dev/null +++ b/apps/wei/forms/surveys/wei2025.py @@ -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} ') + 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() diff --git a/apps/wei/migrations/0011_alter_weiclub_year.py b/apps/wei/migrations/0011_alter_weiclub_year.py new file mode 100644 index 00000000..086ea4eb --- /dev/null +++ b/apps/wei/migrations/0011_alter_weiclub_year.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-05-25 12:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wei', '0010_remove_weiregistration_specific_diet'), + ] + + operations = [ + migrations.AlterField( + model_name='weiclub', + name='year', + field=models.PositiveIntegerField(default=2025, unique=True, verbose_name='year'), + ), + ] diff --git a/apps/wei/migrations/0012_bus_club.py b/apps/wei/migrations/0012_bus_club.py new file mode 100644 index 00000000..80f2e14b --- /dev/null +++ b/apps/wei/migrations/0012_bus_club.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.21 on 2025-05-29 16:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('member', '0014_create_bda'), + ('wei', '0011_alter_weiclub_year'), + ] + + operations = [ + migrations.AddField( + model_name='bus', + name='club', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bus', to='member.club', verbose_name='club'), + ), + ] diff --git a/apps/wei/migrations/0013_weiclub_caution_amount_weiregistration_caution_type.py b/apps/wei/migrations/0013_weiclub_caution_amount_weiregistration_caution_type.py new file mode 100644 index 00000000..49a9a4b6 --- /dev/null +++ b/apps/wei/migrations/0013_weiclub_caution_amount_weiregistration_caution_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.21 on 2025-06-01 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wei', '0012_bus_club'), + ] + + operations = [ + migrations.AddField( + model_name='weiclub', + name='caution_amount', + field=models.PositiveIntegerField(default=0, verbose_name='caution amount'), + ), + migrations.AddField( + model_name='weiregistration', + name='caution_type', + field=models.CharField(choices=[('check', 'Check'), ('note', 'Note transaction')], default='check', max_length=16, verbose_name='caution type'), + ), + ] diff --git a/apps/wei/migrations/0014_weiclub_fee_soge_credit.py b/apps/wei/migrations/0014_weiclub_fee_soge_credit.py new file mode 100644 index 00000000..c353ec0f --- /dev/null +++ b/apps/wei/migrations/0014_weiclub_fee_soge_credit.py @@ -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'), + ), + ] diff --git a/apps/wei/migrations/0015_remove_weiclub_caution_amount_and_more.py b/apps/wei/migrations/0015_remove_weiclub_caution_amount_and_more.py new file mode 100644 index 00000000..950b4965 --- /dev/null +++ b/apps/wei/migrations/0015_remove_weiclub_caution_amount_and_more.py @@ -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'), + ), + ] diff --git a/apps/wei/migrations/0016_weiregistration_fee_alter_weiclub_fee_soge_credit.py b/apps/wei/migrations/0016_weiregistration_fee_alter_weiclub_fee_soge_credit.py new file mode 100644 index 00000000..6d2d1289 --- /dev/null +++ b/apps/wei/migrations/0016_weiregistration_fee_alter_weiclub_fee_soge_credit.py @@ -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)'), + ), + ] diff --git a/apps/wei/migrations/0017_alter_weiclub_fee_soge_credit.py b/apps/wei/migrations/0017_alter_weiclub_fee_soge_credit.py new file mode 100644 index 00000000..c78ffdd4 --- /dev/null +++ b/apps/wei/migrations/0017_alter_weiclub_fee_soge_credit.py @@ -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)'), + ), + ] diff --git a/apps/wei/migrations/0018_remove_weiregistration_deposit_check_and_more.py b/apps/wei/migrations/0018_remove_weiregistration_deposit_check_and_more.py new file mode 100644 index 00000000..fbc5cc8a --- /dev/null +++ b/apps/wei/migrations/0018_remove_weiregistration_deposit_check_and_more.py @@ -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'), + ), + ] diff --git a/apps/wei/models.py b/apps/wei/models.py index f4169316..5aa4e94f 100644 --- a/apps/wei/models.py +++ b/apps/wei/models.py @@ -33,6 +33,16 @@ class WEIClub(Club): verbose_name=_("date end"), ) + deposit_amount = models.PositiveIntegerField( + verbose_name=_("deposit amount"), + default=0, + ) + + fee_soge_credit = models.PositiveIntegerField( + verbose_name=_("membership fee (soge credit)"), + default=0, + ) + class Meta: verbose_name = _("WEI") verbose_name_plural = _("WEI") @@ -72,6 +82,15 @@ class Bus(models.Model): default=50, ) + club = models.OneToOneField( + Club, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="bus", + verbose_name=_("club"), + ) + description = models.TextField( blank=True, default="", @@ -183,9 +202,19 @@ class WEIRegistration(models.Model): verbose_name=_("Credit from Société générale"), ) - caution_check = models.BooleanField( + deposit_given = models.BooleanField( default=False, - verbose_name=_("Caution check given") + verbose_name=_("Deposit given") + ) + + deposit_type = models.CharField( + max_length=16, + choices=( + ('check', _("Check")), + ('note', _("Note transaction")), + ), + default='check', + verbose_name=_("deposit type"), ) birth_date = models.DateField( @@ -256,6 +285,12 @@ class WEIRegistration(models.Model): "encoded in JSON"), ) + fee = models.PositiveIntegerField( + default=0, + verbose_name=_('fee'), + blank=True, + ) + class Meta: unique_together = ('user', 'wei',) verbose_name = _("WEI User") @@ -280,7 +315,25 @@ class WEIRegistration(models.Model): self.information_json = json.dumps(information, indent=2) @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) kfet = Club.objects.get(pk=2) @@ -295,7 +348,8 @@ class WEIRegistration(models.Model): date_start__gte=bde.membership_start, ).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 if not kfet_member: fee += kfet.membership_fee_paid if self.user.profile.paid \ @@ -306,12 +360,9 @@ class WEIRegistration(models.Model): return fee - @property - def is_validated(self): - try: - return self.membership is not None - except AttributeError: - return False + def save(self, *args, **kwargs): + self.fee = self.calculate_fee() + super().save(*args, **kwargs) class WEIMembership(Membership): diff --git a/apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg b/apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg new file mode 100644 index 00000000..d95f496b Binary files /dev/null and b/apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg differ diff --git a/apps/wei/tables.py b/apps/wei/tables.py index de5c84af..362bdf9c 100644 --- a/apps/wei/tables.py +++ b/apps/wei/tables.py @@ -58,8 +58,8 @@ class WEIRegistrationTable(tables.Table): validate = tables.Column( verbose_name=_("Validate"), - orderable=False, - accessor=A('pk'), + orderable=True, + accessor='validate_status', attrs={ 'th': { 'id': 'validate-membership-header' @@ -71,7 +71,7 @@ class WEIRegistrationTable(tables.Table): 'wei:wei_delete_registration', args=[A('pk')], orderable=False, - verbose_name=_("delete"), + verbose_name=_("Delete"), text=_("Delete"), attrs={ '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(""" + + + + """) + if record.deposit_type == 'note': + return format_html("") + def render_validate(self, record): hasperm = PermissionBackend.check_perm( get_current_request(), "wei.add_weimembership", WEIMembership( @@ -100,10 +129,11 @@ class WEIRegistrationTable(tables.Table): url = reverse_lazy('wei:validate_registration', args=(record.pk,)) 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' tooltip = _("The user does not have enough money.") - elif record.first_year: + elif status == 1: btn_class = 'btn-info' tooltip = _("The user is in first year. You may validate the credit, the algorithm will run later.") else: @@ -121,10 +151,11 @@ class WEIRegistrationTable(tables.Table): attrs = { 'class': 'table table-condensed table-striped table-hover' } + order_by = ('validate', 'user',) model = WEIRegistration template_name = 'django_tables2/bootstrap4.html' - fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'caution_check', - 'edit', 'validate', 'delete',) + fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'deposit_given', + 'deposit_type', 'edit', 'validate', 'delete',) row_attrs = { 'class': 'table-row', 'id': lambda record: "row-" + str(record.pk), @@ -134,8 +165,8 @@ class WEIRegistrationTable(tables.Table): class WEIMembershipTable(tables.Table): user = tables.LinkColumn( - 'wei:wei_update_registration', - args=[A('registration__pk')], + 'wei:wei_update_membership', + args=[A('pk')], ) year = tables.Column( @@ -156,6 +187,35 @@ class WEIMembershipTable(tables.Table): def render_year(self, record): 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(""" + + + + """) + if record.registration.deposit_type == 'note': + return format_html("") + class Meta: attrs = { 'class': 'table table-condensed table-striped table-hover' @@ -163,7 +223,7 @@ class WEIMembershipTable(tables.Table): model = WEIMembership template_name = 'django_tables2/bootstrap4.html' 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 = { 'class': 'table-row', 'id': lambda record: "row-" + str(record.pk), diff --git a/apps/wei/templates/wei/base.html b/apps/wei/templates/wei/base.html index 43d61797..2975efc0 100644 --- a/apps/wei/templates/wei/base.html +++ b/apps/wei/templates/wei/base.html @@ -40,22 +40,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans 'membership fee'|capfirst %}
{{ club.membership_fee_paid|pretty_money }}
{% else %} - {% with bde_kfet_fee=club.parent_club.membership_fee_paid|add:club.parent_club.parent_club.membership_fee_paid %} -
{% trans 'WEI fee (paid students)'|capfirst %}
-
{{ club.membership_fee_paid|add:bde_kfet_fee|pretty_money }} -
- {% endwith %} - {% with bde_kfet_fee=club.parent_club.membership_fee_unpaid|add:club.parent_club.parent_club.membership_fee_unpaid %} +
{% trans 'WEI fee (paid students)'|capfirst %}
+
{{ club.membership_fee_paid|pretty_money }} +
{% trans 'WEI fee (unpaid students)'|capfirst %}
-
{{ club.membership_fee_unpaid|add:bde_kfet_fee|pretty_money }} -
- {% endwith %} +
{{ club.membership_fee_unpaid|pretty_money }} {% endif %} {% endif %} + {% if club.deposit_amount > 0 %} +
{% trans 'deposit amount'|capfirst %}
+
{{ club.deposit_amount|pretty_money }}
+ {% endif %} + {% if "note.view_note"|has_perm:club.note %}
{% trans 'balance'|capfirst %}
{{ club.note.balance | pretty_money }}
diff --git a/apps/wei/templates/wei/bus_detail.html b/apps/wei/templates/wei/bus_detail.html index c8f3ce20..0f521061 100644 --- a/apps/wei/templates/wei/bus_detail.html +++ b/apps/wei/templates/wei/bus_detail.html @@ -16,8 +16,14 @@ SPDX-License-Identifier: GPL-3.0-or-later diff --git a/apps/wei/templates/wei/busteam_detail.html b/apps/wei/templates/wei/busteam_detail.html index 27348d03..1b5dc3c3 100644 --- a/apps/wei/templates/wei/busteam_detail.html +++ b/apps/wei/templates/wei/busteam_detail.html @@ -18,6 +18,8 @@ SPDX-License-Identifier: GPL-3.0-or-later diff --git a/apps/wei/templates/wei/busteam_form.html b/apps/wei/templates/wei/busteam_form.html index c62fec40..24522a80 100644 --- a/apps/wei/templates/wei/busteam_form.html +++ b/apps/wei/templates/wei/busteam_form.html @@ -13,9 +13,17 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% csrf_token %} + {{ form.media }} {{ form|crispy }}
+ {% endblock %} \ No newline at end of file diff --git a/apps/wei/templates/wei/weiclub_detail.html b/apps/wei/templates/wei/weiclub_detail.html index cd4b5efb..c820c10d 100644 --- a/apps/wei/templates/wei/weiclub_detail.html +++ b/apps/wei/templates/wei/weiclub_detail.html @@ -31,15 +31,29 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Register to the WEI! – 1A" %} - {% endif %} - - {% trans "Register to the WEI! – 2A+" %} {% else %} + + {% trans "Register to the WEI! – 2A+" %} + + {% endif %} + {% else %} + {% if registration.validated %} {% trans "Update my registration" %} {% endif %} + {% if my_registration.first_year %} + {% if not survey_complete %} + + {% trans "Continue survey" %} + + {% endif %} + + {% trans "Restart survey" %} + + {% endif %} + {% endif %} {% endif %} @@ -67,20 +81,6 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endif %} -{% if history_list.data %} -
- -
- {% render_table history_list %} -
-
-{% endif %} - {% if pre_registrations.data %}
@@ -95,9 +95,24 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} - {% if can_validate_1a %} - {% trans "Attribute buses" %} - {% endif %} +{% if can_validate_1a %} + {% trans "Attribute buses" %} +{% endif %} + +{% if history_list.data %} +
+ +
+ {% render_table history_list %} +
+
+{% endif %} + {% endblock %} {% block extrajavascript %} diff --git a/apps/wei/templates/wei/weimembership_form.html b/apps/wei/templates/wei/weimembership_form.html index ff3024ca..d73c1be0 100644 --- a/apps/wei/templates/wei/weimembership_form.html +++ b/apps/wei/templates/wei/weimembership_form.html @@ -95,8 +95,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "The algorithm didn't run." %}
{% endif %} {% else %} -
{% trans 'caution check given'|capfirst %}
-
{{ registration.caution_check|yesno }}
+
{% trans 'Deposit check given'|capfirst %}
+
{{ registration.deposit_given|yesno }}
{% with information=registration.information %}
{% trans 'preferred bus'|capfirst %}
@@ -137,33 +137,41 @@ SPDX-License-Identifier: GPL-3.0-or-later {% if registration.soge_credit %}
{% 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 validated the creation of the account, or to change the payment method. {% endblocktrans %}
- {% else %} - {% if registration.user.note.balance < fee %} -
- {% with pretty_fee=fee|pretty_money %} - {% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %} - The note don't have enough money ({{ balance }}, {{ pretty_fee }} required). - The registration may fail if you don't credit the note now. - {% endblocktrans %} - {% endwith %} -
- {% else %} -
- {% blocktrans trimmed with pretty_fee=fee|pretty_money %} - The note has enough money ({{ pretty_fee }} required), the registration is possible. - {% endblocktrans %} -
- {% endif %} {% endif %} +
+
{% trans "Required payments:" %}
+
    +
  • {% blocktrans trimmed with amount=fee|pretty_money %} + Membership fees: {{ amount }} + {% endblocktrans %}
  • + {% if not registration.first_year %} + {% if registration.deposit_type == 'note' %} +
  • {% blocktrans trimmed with amount=club.deposit_amount|pretty_money %} + Deposit (by Note transaction): {{ amount }} + {% endblocktrans %}
  • + {% else %} +
  • {% blocktrans trimmed with amount=club.deposit_amount|pretty_money %} + Deposit (by check): {{ amount }} + {% endblocktrans %}
  • + {% endif %} + {% endif %} +
  • {% blocktrans trimmed with total=total_needed|pretty_money %} + Total needed: {{ total }} + {% endblocktrans %}
  • +
+

{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %} + Current balance: {{ balance }} + {% endblocktrans %}

+
- {% if not registration.caution_check and not registration.first_year %} + {% if not registration.deposit_given and not registration.first_year and registration.caution_type == 'check' %}
- {% trans "The user didn't give her/his caution check." %} + {% trans "The user didn't give her/his caution." %}
{% endif %} @@ -200,4 +208,26 @@ SPDX-License-Identifier: GPL-3.0-or-later } } + {% endblock %} diff --git a/apps/wei/templates/wei/weimembership_update.html b/apps/wei/templates/wei/weimembership_update.html new file mode 100644 index 00000000..527ac08d --- /dev/null +++ b/apps/wei/templates/wei/weimembership_update.html @@ -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 %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + +
+
+
+{% endblock %} + +{% block extrajavascript %} + +{% endblock %} \ No newline at end of file diff --git a/apps/wei/templates/wei/weiregistration_form.html b/apps/wei/templates/wei/weiregistration_form.html index fae85e0f..dc5f66e5 100644 --- a/apps/wei/templates/wei/weiregistration_form.html +++ b/apps/wei/templates/wei/weiregistration_form.html @@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {{ title }}
-
+ {% csrf_token %} {{ form|crispy }} {{ membership_form|crispy }} @@ -22,6 +22,46 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endblock %} {% block extrajavascript %} + + + {% if not object.membership %} @@ -40,6 +43,8 @@ SPDX-License-Identifier: GPL-3.0-or-later {# Translation in javascript files #} + + {# If extra ressources are needed for a form, load here #} {% if form.media %} {{ form.media }} @@ -78,6 +83,13 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans 'Transfer' %} {% endif %} + {% if user.is_authenticated %} + + {% endif %} + {% if "auth.user"|model_list_length >= 2 %}
{% else %} @@ -193,7 +208,11 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endblocktrans %}
{% endif %} - {# TODO Add banners #} + {% if config.BANNER_MESSAGE and user.is_authenticated %} +
+ {{ config.BANNER_MESSAGE }} +
+ {% endif %} {% block content %}

Default content...

@@ -215,6 +234,10 @@ SPDX-License-Identifier: GPL-3.0-or-later class="text-muted">{% trans "Charte Info (FR)" %} — {% trans "FAQ (FR)" %} — + {% trans "Managed by BDE" %} — + {% trans "Hosted by Cr@ns" %} — {% csrf_token %} {% trans "Log me out from all my sessions" %} + + + {% if settings.CAS_FEDERATE and request.COOKIES.remember_provider %} +
+ +
+ {% endif %} + + + + +{% endblock %} diff --git a/note_kfet/templates/cas/login.html b/note_kfet/templates/cas/login.html new file mode 100644 index 00000000..84fd0cf8 --- /dev/null +++ b/note_kfet/templates/cas/login.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% comment %} +Copyright (C) by BDE ENS-Paris-Saclay +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n %} + +{% block ante_messages %} + {% if auto_submit %}{% endif %} +{% endblock %} + +{% block content %} +
+
+
+ +
+ +{% endblock %} + +{% block javascript_inline %} +jQuery(function( $ ){ + $("#id_warn").click(function(e){ + if($("#id_warn").is(':checked')){ + createCookie("warn", "on", 10 * 365); + } else { + eraseCookie("warn"); + } + }); +}); +{% if auto_submit %}document.getElementById('login_form').submit(); // SUBMIT FORM{% endif %} +{% endblock %} diff --git a/note_kfet/templates/cas/logout.html b/note_kfet/templates/cas/logout.html new file mode 100644 index 00000000..814f7d33 --- /dev/null +++ b/note_kfet/templates/cas/logout.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% comment %} +Copyright (C) by BDE ENS-Paris-Saclay +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n static %} +{% block content %} + +{% endblock %} + diff --git a/note_kfet/templates/cas/warn.html b/note_kfet/templates/cas/warn.html new file mode 100644 index 00000000..89ee1815 --- /dev/null +++ b/note_kfet/templates/cas/warn.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% comment %} +Copyright (C) by BDE ENS-Paris-Saclay +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n static %} + +{% block content %} +
+
+ +
+
+{% endblock %} + diff --git a/note_kfet/templates/colorfield/color.html b/note_kfet/templates/colorfield/color.html new file mode 100644 index 00000000..5c0457c5 --- /dev/null +++ b/note_kfet/templates/colorfield/color.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/note_kfet/templates/registration/login.html b/note_kfet/templates/registration/login.html index b30c9fb6..6523f529 100644 --- a/note_kfet/templates/registration/login.html +++ b/note_kfet/templates/registration/login.html @@ -39,6 +39,23 @@ SPDX-License-Identifier: GPL-2.0-or-later {% trans 'Forgotten your password or username?' %} + +
+ {% now "Ymd" as current_date_str %} + + {% if display_appstore_badge %} + + {% trans 'Download on the AppStore' %} + + {% endif %} + {% if display_playstore_badge %} + + {% trans 'Get it on Google Play' %} + + {% endif %} +
{% endblock %} \ No newline at end of file diff --git a/note_kfet/templates/registration/signup.html b/note_kfet/templates/registration/signup.html index 7bd503eb..71fe2511 100644 --- a/note_kfet/templates/registration/signup.html +++ b/note_kfet/templates/registration/signup.html @@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endblocktrans %} -
+ {% csrf_token %} {{ form|crispy }} {{ profile_form|crispy }} @@ -31,3 +31,45 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endblock %} + +{% block extrajavascript %} + + +{% endblock %} \ No newline at end of file diff --git a/note_kfet/urls.py b/note_kfet/urls.py index fb4b2323..733e3bb7 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -21,8 +21,9 @@ urlpatterns = [ path('activity/', include('activity.urls')), path('treasury/', include('treasury.urls')), path('wei/', include('wei.urls')), - path('food/',include('food.urls')), - path('wrapped/',include('wrapped.urls')), + path('food/', include('food.urls')), + path('wrapped/', include('wrapped.urls')), + path('family/', include('family.urls')), # Include Django Contrib and Core routers path('i18n/', include('django.conf.urls.i18n')), diff --git a/requirements.txt b/requirements.txt index f4a32c97..464cd4ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,22 @@ -beautifulsoup4~=4.12.3 -crispy-bootstrap4~=2023.1 -Django~=4.2.9 +beautifulsoup4~=4.13.4 +crispy-bootstrap4~=2025.6 +Django~=5.2.4 django-bootstrap-datepicker-plus~=5.0.5 -#django-cas-server~=2.0.0 -django-colorfield~=0.11.0 -django-crispy-forms~=2.1.0 -django-extensions>=3.2.3 -django-filter~=23.5 +django-cas-server~=3.1.0 +django-colorfield~=0.14.0 +django-constance~=4.3.2 +django-crispy-forms~=2.4.0 +django-extensions>=4.1.0 +django-filter~=25.1 #django-htcpcp-tea~=0.8.1 -django-mailer~=2.3.1 -django-oauth-toolkit~=2.3.0 -django-phonenumber-field~=7.3.0 -django-polymorphic~=3.1.0 -djangorestframework~=3.14.0 +django-mailer~=2.3.2 +django-oauth-toolkit~=3.0.1 +django-phonenumber-field~=8.1.0 +django-polymorphic~=4.1.0 +djangorestframework~=3.16.0 django-rest-polymorphic~=0.1.10 -django-tables2~=2.7.0 +django-tables2~=2.7.5 python-memcached~=1.62 -phonenumbers~=8.13.28 -Pillow>=10.2.0 +phonenumbers~=9.0.8 +tablib~=3.8.0 +Pillow>=11.3.0 diff --git a/tox.ini b/tox.ini index 1bfeb593..1fa7cf7a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,13 @@ [tox] envlist = # Ubuntu 22.04 Python - py310-django42 + py310-django52 # Debian Bookworm Python - py311-django42 + py311-django52 # Ubuntu 24.04 Python - py312-django42 + py312-django52 linters skipsdist = True @@ -32,8 +32,7 @@ deps = pep8-naming pyflakes commands = - flake8 apps --extend-exclude apps/scripts,apps/wrapped/management/commands - flake8 apps/wrapped/management/commands --extend-ignore=C901 + flake8 apps --extend-exclude apps/scripts [flake8] ignore = W503, I100, I101, B019