1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-21 09:58:23 +02:00

Compare commits

...

62 Commits

Author SHA1 Message Date
5c5f579729 Traductions 2025-06-20 14:24:03 +02:00
a6df0e7c69 Autres permissions 2025-06-17 20:51:46 +02:00
6822500fdc Correction des tests et autres 2025-06-12 17:39:34 +02:00
63f6528adc Suppression du choix GC WEI dans les roles 2025-06-12 13:59:59 +02:00
40ac1daece Tests et permissions 2025-06-02 17:51:33 +02:00
e617048332 Meilleure gestion des cautions 2025-06-02 01:09:51 +02:00
9eb6edb37d Problème de membership fee 2025-05-29 23:15:33 +02:00
70a57bf02d Ajout d'un champ club au modèle Bus pour faciliter la gestion des bus 2025-05-29 20:16:43 +02:00
02453e07ba linters 2025-05-28 16:31:03 +02:00
4479e8f97a Fix de views.py et tests de permissions 2025-05-28 16:04:19 +02:00
a351415494 Fix des tests de apps/wei 2025-05-28 15:37:37 +02:00
16cfaa809a Fix de la plupart des bugs 2025-05-27 18:56:49 +02:00
f2cd0b6d36 Merge branch 'wei' of gitlab.crans.org:bde/nk20 into wei 2025-05-26 18:15:51 +02:00
a2e2ff5fa9 Merge branch 'main' into 'wei'
Main

See merge request bde/nk20!319
2025-05-26 17:51:33 +02:00
53d0480a12 Ajout de permissions 2025-05-26 17:29:34 +02:00
ff812a028c Merge branch 'darbonne' into 'main'
Darbonne

See merge request bde/nk20!318
2025-05-26 16:47:03 +02:00
136f636fda Fix de l'ajout d'équipe, le ColorWidget était défaillant 2025-05-25 23:31:09 +02:00
5a8acbde00 Trez TaT en moins 2025-05-25 00:07:07 +02:00
f60dc8cfa0 Pré-injection du BDA 2025-05-25 00:05:13 +02:00
067dd6f9d1 WEI-Roles 2025-05-24 22:41:53 +02:00
7b1e32e514 Réécriture des rôles pertinents 2025-05-24 22:29:11 +02:00
e88dbfd597 Merge branch 'darbonne' into 'main'
Faute de frappe

See merge request bde/nk20!317
2025-05-23 23:57:18 +02:00
3d34270959 Faute de frappe 2025-05-23 23:38:06 +02:00
3bb99671ec Merge branch 'ehouarn-main-patch-70724' into 'main'
Update views.py

See merge request bde/nk20!316
2025-05-19 18:03:00 +02:00
0d69383dfd Update views.py 2025-05-19 17:45:01 +02:00
7b9ff119e8 Merge branch 'food_bugs' into 'main'
Corrections de quelques bugs (par Quark)

See merge request bde/nk20!315
2025-05-10 19:46:44 +02:00
108a56745c Corrections de quelques bugs (par Quark) 2025-05-10 19:24:05 +02:00
9643d7652b Merge branch 'delete_activity' into 'main'
migrations

See merge request bde/nk20!314
2025-05-09 20:15:46 +02:00
fadb289ed7 migrations 2025-05-09 19:48:04 +02:00
905fc6e7cc Merge branch 'delete_activity' into 'main'
Delete activity

See merge request bde/nk20!313
2025-05-08 20:28:21 +02:00
cdd81c1444 Update views.py 2025-05-08 20:14:24 +02:00
4afafceba1 Update activity_detail.html 2025-05-08 19:39:59 +02:00
3065eacc96 Update views.py 2025-05-08 19:38:40 +02:00
71ef3aedd8 Update views.py 2025-05-08 19:09:22 +02:00
0cf11c6348 ok 2025-05-08 18:34:23 +02:00
70abd0f490 Merge branch 'food_traceability' into 'main'
Remove food with end_of_life not null from open table

See merge request bde/nk20!312
2025-05-07 18:26:40 +02:00
4445dd4a96 Remove food with end_of_life not null from open table 2025-05-07 18:04:47 +02:00
03932672f3 Merge branch 'food_traceability' into 'main'
bug fix and doc

See merge request bde/nk20!311
2025-05-04 20:17:51 +02:00
dc6a40de02 bug fix and doc 2025-05-04 17:56:44 +02:00
d58a299a8b Merge branch 'food_traceability' into 'main'
Add manage ingredient feature, fix some bug

See merge request bde/nk20!310
2025-04-30 12:38:32 +02:00
ad0a219ed3 Add manage ingredient feature, fix some bug 2025-04-30 12:06:37 +02:00
c4404ef995 Merge branch 'food_traceability' into 'main'
fix bug

See merge request bde/nk20!309
2025-04-28 13:35:17 +02:00
b4f3a158a6 fix permission bug 2025-04-28 13:18:33 +02:00
f0e9a7d3dc Merge branch 'food_traceability' into 'main'
Food traceability

See merge request bde/nk20!308
2025-04-27 09:36:46 +02:00
a2b42c5329 permission, fixture, translation (fr), bug fixes 2025-04-24 20:50:32 +02:00
6d6583bfe6 Rewrite food apps, new feature some changes to model 2025-04-22 19:52:32 +02:00
485d093002 here we go again (better this time) 2025-04-16 17:26:00 +02:00
ff4353d344 Merge branch 'update_invoice_template' into 'main'
Update invoice template

See merge request bde/nk20!307
2025-04-15 18:11:18 +02:00
a90f45bd8b Replace Diolistos.png 2025-04-15 17:38:45 +02:00
10c22ccc53 Replace Diolistos_bg.jpg 2025-04-15 17:38:26 +02:00
6969cee0f3 Merge branch 'time-display' into 'main'
Fixed some non timezone-aware displays

See merge request bde/nk20!303
2025-04-15 17:28:28 +02:00
ddeada200b Changement logo factures 2025-04-15 17:26:14 +02:00
8e2b24b2da Merge branch 'options_order' into 'main'
Options order

See merge request bde/nk20!306
2025-04-13 23:12:05 +02:00
bd76c280ec Update forms.py 2025-04-13 22:59:04 +02:00
ca0a95ba9e Update transaction_form.html 2025-04-13 22:32:49 +02:00
614f76e699 Add BDA email as an option to fix NL updates
Commit [1] removed default BDA email address from extract_ml_registrations script code. We should now add it to cron.

[1] 3dd5f6e3e0

See merge request bde/nk20!305
2025-04-13 20:17:44 +02:00
a5815f0bc7 Add BDA email as an option to fix NL updates
Commit [1] removed default BDA email address from extract_ml_registrations script code. We should now add it to cron.

[1] 3dd5f6e3e0
2025-04-13 19:56:20 +02:00
84e9fea15f linters 2025-04-04 14:46:43 +02:00
b7a660ee40 bootstrap: fix minor issues with profile picture cropping
* Add required [1] "display: block;" style property to img element
* Fix image overflow in modal. As cropper size inherits from img's parent element [2] (including padding according to my research), we need to wrap modal body into another div that has the padding we want.
* Remove ability [3] to click away to dismiss the modal as it often interfered with user interaction when cropping.

[1] https://github.com/fengyuanchen/cropperjs/tree/v1?tab=readme-ov-file#example
[2] https://github.com/fengyuanchen/cropperjs/tree/v1?tab=readme-ov-file#notes
[3] https://getbootstrap.com/docs/4.0/components/modal/#options

See merge request bde/nk20!301
2025-04-04 01:14:46 +02:00
b9ebb1718a Fixed some non timezone-aware displays 2025-04-04 00:29:22 +02:00
7ba5c76a89 Merge branch 'guests_schools' into 'main'
add school field to guest

See merge request bde/nk20!302
2025-03-25 18:59:46 +01:00
93aed87265 bootstrap: fix minor issues with profile picture cropping
* Add required [1] "display: block;" style property to img element
* Fix image overflow in modal. As cropper size inherits from img's parent element [2] (including padding according to my research), we need to wrap modal body into another div that has the padding we want.
* Remove ability [3] to click away to dismiss the modal as it often interfered with user interaction when cropping.

[1] https://github.com/fengyuanchen/cropperjs/tree/v1?tab=readme-ov-file#example
[2] https://github.com/fengyuanchen/cropperjs/tree/v1?tab=readme-ov-file#notes
[3] https://getbootstrap.com/docs/4.0/components/modal/#options

Signed-off-by: Alexis Mercier des Rochettes <apernouille@gmail.com>
2025-03-24 17:36:30 +01:00
72 changed files with 7348 additions and 3556 deletions

View File

@ -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'),
),
]

View File

@ -201,7 +201,8 @@ class Entry(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest) qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
if qs.exists(): if qs.exists():
raise ValidationError(_("Already entered on ") + _("{:%Y-%m-%d %H:%M:%S}").format(qs.get().time, )) raise ValidationError(_("Already entered on ")
+ _("{:%Y-%m-%d %H:%M:%S}").format(timezone.localtime(qs.get().time), ))
if self.guest: if self.guest:
self.note = self.guest.inviter self.note = self.guest.inviter
@ -233,7 +234,7 @@ class Guest(models.Model):
""" """
activity = models.ForeignKey( activity = models.ForeignKey(
Activity, Activity,
on_delete=models.PROTECT, on_delete=models.CASCADE,
related_name='+', related_name='+',
) )

View File

@ -55,7 +55,7 @@ class GuestTable(tables.Table):
def render_entry(self, record): def render_entry(self, record):
if record.has_entry: if record.has_entry:
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, ))) return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(timezone.localtime(record.entry.time))))
return mark_safe('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> ' return mark_safe('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> '
'{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize())) '{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize()))

View File

@ -95,5 +95,23 @@ SPDX-License-Identifier: GPL-3.0-or-later
errMsg(xhr.responseJSON); 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);
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -70,7 +70,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if ".change_"|has_perm:activity %} {% if ".change_"|has_perm:activity %}
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_update' pk=activity.pk %}" data-turbolinks="false"> {% trans "edit"|capfirst %}</a> <a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_update' pk=activity.pk %}" data-turbolinks="false"> {% trans "edit"|capfirst %}</a>
{% endif %} {% endif %}
{% if activity.activity_type.can_invite and not activity_started %} {% if not activity.valid and ".delete_"|has_perm:activity %}
<a class="btn btn-danger btn-sm my-1" id="delete_activity"> {% trans "delete"|capfirst %} </a>
{% endif %}
{% if activity.activity_type.can_invite and not activity_started and activity.valid %}
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_invite' pk=activity.pk %}" data-turbolinks="false"> {% trans "Invite" %}</a> <a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_invite' pk=activity.pk %}" data-turbolinks="false"> {% trans "Invite" %}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@ -15,4 +15,5 @@ urlpatterns = [
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'), path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
path('new/', views.ActivityCreateView.as_view(), name='activity_create'), path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'), path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'),
path('<int:pk>/delete', views.ActivityDeleteView.as_view(), name='delete_activity'),
] ]

View File

@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.http import HttpResponse from django.http import HttpResponse, JsonResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -153,6 +153,34 @@ class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
class ActivityDeleteView(View):
"""
Deletes an Activity
"""
def delete(self, request, pk):
try:
activity = Activity.objects.get(pk=pk)
activity.delete()
return JsonResponse({"message": "Activity deleted"})
except Activity.DoesNotExist:
return JsonResponse({"error": "Activity not found"}, status=404)
def dispatch(self, *args, **kwargs):
"""
Don't display the delete button if the user has no right to delete.
"""
if not self.request.user.is_authenticated:
return self.handle_no_permission()
activity = Activity.objects.get(pk=self.kwargs["pk"])
if not PermissionBackend.check_perm(self.request, "activity.delete_activity", activity):
raise PermissionDenied(_("You are not allowed to delete this activity."))
if activity.valid:
raise PermissionDenied(_("This activity is valid."))
return super().dispatch(*args, **kwargs)
class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
""" """
Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm` Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`

View File

@ -2,36 +2,58 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin from django.contrib import admin
from django.db import transaction from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
from note_kfet.admin import admin_site from note_kfet.admin import admin_site
from .models import Allergen, BasicFood, QRCode, TransformedFood from .models import Allergen, Food, BasicFood, TransformedFood, QRCode
@admin.register(QRCode, site=admin_site)
class QRCodeAdmin(admin.ModelAdmin):
pass
@admin.register(BasicFood, site=admin_site)
class BasicFoodAdmin(admin.ModelAdmin):
@transaction.atomic
def save_related(self, *args, **kwargs):
ans = super().save_related(*args, **kwargs)
args[1].instance.update()
return ans
@admin.register(TransformedFood, site=admin_site)
class TransformedFoodAdmin(admin.ModelAdmin):
exclude = ["allergens", "expiry_date"]
@transaction.atomic
def save_related(self, request, form, *args, **kwargs):
super().save_related(request, form, *args, **kwargs)
form.instance.update()
@admin.register(Allergen, site=admin_site) @admin.register(Allergen, site=admin_site)
class AllergenAdmin(admin.ModelAdmin): class AllergenAdmin(admin.ModelAdmin):
pass """
Admin customisation for Allergen
"""
ordering = ['name']
@admin.register(Food, site=admin_site)
class FoodAdmin(PolymorphicParentModelAdmin):
"""
Admin customisation for Food
"""
child_models = (Food, BasicFood, TransformedFood)
list_display = ('name', 'expiry_date', 'owner', 'is_ready')
list_filter = ('is_ready', 'end_of_life')
search_fields = ['name']
ordering = ['expiry_date', 'name']
@admin.register(BasicFood, site=admin_site)
class BasicFood(PolymorphicChildModelAdmin):
"""
Admin customisation for BasicFood
"""
list_display = ('name', 'expiry_date', 'date_type', 'owner', 'is_ready')
list_filter = ('is_ready', 'date_type', 'end_of_life')
search_fields = ['name']
ordering = ['expiry_date', 'name']
@admin.register(TransformedFood, site=admin_site)
class TransformedFood(PolymorphicChildModelAdmin):
"""
Admin customisation for TransformedFood
"""
list_display = ('name', 'expiry_date', 'shelf_life', 'owner', 'is_ready')
list_filter = ('is_ready', 'end_of_life', 'shelf_life')
search_fields = ['name']
ordering = ['expiry_date', 'name']
@admin.register(QRCode, site=admin_site)
class QRCodeAdmin(admin.ModelAdmin):
"""
Admin customisation for QRCode
"""
list_diplay = ('qr_code_number', 'food_container')
search_fields = ['food_container__name']

View File

@ -3,7 +3,7 @@
from rest_framework import serializers from rest_framework import serializers
from ..models import Allergen, BasicFood, QRCode, TransformedFood from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
class AllergenSerializer(serializers.ModelSerializer): class AllergenSerializer(serializers.ModelSerializer):
@ -11,40 +11,46 @@ class AllergenSerializer(serializers.ModelSerializer):
REST API Serializer for Allergen. REST API Serializer for Allergen.
The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API. The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
""" """
class Meta: class Meta:
model = Allergen model = Allergen
fields = '__all__' fields = '__all__'
class FoodSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Food.
The djangorestframework plugin will analyse the model `Food` and parse all fields in the API.
"""
class Meta:
model = Food
fields = '__all__'
class BasicFoodSerializer(serializers.ModelSerializer): class BasicFoodSerializer(serializers.ModelSerializer):
""" """
REST API Serializer for BasicFood. REST API Serializer for BasicFood.
The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API. The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
""" """
class Meta: class Meta:
model = BasicFood model = BasicFood
fields = '__all__' fields = '__all__'
class QRCodeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for QRCode.
The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
"""
class Meta:
model = QRCode
fields = '__all__'
class TransformedFoodSerializer(serializers.ModelSerializer): class TransformedFoodSerializer(serializers.ModelSerializer):
""" """
REST API Serializer for TransformedFood. REST API Serializer for TransformedFood.
The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API. The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
""" """
class Meta: class Meta:
model = TransformedFood model = TransformedFood
fields = '__all__' fields = '__all__'
class QRCodeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for QRCode.
The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
"""
class Meta:
model = QRCode
fields = '__all__'

View File

@ -1,7 +1,7 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import AllergenViewSet, BasicFoodViewSet, QRCodeViewSet, TransformedFoodViewSet from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
def register_food_urls(router, path): def register_food_urls(router, path):
@ -9,6 +9,7 @@ def register_food_urls(router, path):
Configure router for Food REST API. Configure router for Food REST API.
""" """
router.register(path + '/allergen', AllergenViewSet) router.register(path + '/allergen', AllergenViewSet)
router.register(path + '/basic_food', BasicFoodViewSet) router.register(path + '/food', FoodViewSet)
router.register(path + '/basicfood', BasicFoodViewSet)
router.register(path + '/transformedfood', TransformedFoodViewSet)
router.register(path + '/qrcode', QRCodeViewSet) router.register(path + '/qrcode', QRCodeViewSet)
router.register(path + '/transformed_food', TransformedFoodViewSet)

View File

@ -5,8 +5,8 @@ from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from .serializers import AllergenSerializer, BasicFoodSerializer, QRCodeSerializer, TransformedFoodSerializer from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
from ..models import Allergen, BasicFood, QRCode, TransformedFood from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
class AllergenViewSet(ReadProtectedModelViewSet): class AllergenViewSet(ReadProtectedModelViewSet):
@ -22,11 +22,24 @@ class AllergenViewSet(ReadProtectedModelViewSet):
search_fields = ['$name', ] search_fields = ['$name', ]
class FoodViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Food` objects, serialize it to JSON with the given serializer,
then render it on /api/food/food/
"""
queryset = Food.objects.order_by('id')
serializer_class = FoodSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class BasicFoodViewSet(ReadProtectedModelViewSet): class BasicFoodViewSet(ReadProtectedModelViewSet):
""" """
REST API View set. REST API View set.
The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer,
then render it on /api/food/basic_food/ then render it on /api/food/basicfood/
""" """
queryset = BasicFood.objects.order_by('id') queryset = BasicFood.objects.order_by('id')
serializer_class = BasicFoodSerializer serializer_class = BasicFoodSerializer
@ -35,6 +48,19 @@ class BasicFoodViewSet(ReadProtectedModelViewSet):
search_fields = ['$name', ] search_fields = ['$name', ]
class TransformedFoodViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
then render it on /api/food/transformedfood/
"""
queryset = TransformedFood.objects.order_by('id')
serializer_class = TransformedFoodSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class QRCodeViewSet(ReadProtectedModelViewSet): class QRCodeViewSet(ReadProtectedModelViewSet):
""" """
REST API View set. REST API View set.
@ -46,16 +72,3 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['qr_code_number', ] filterset_fields = ['qr_code_number', ]
search_fields = ['$qr_code_number', ] search_fields = ['$qr_code_number', ]
class TransformedFoodViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
then render it on /api/food/transformed_food/
"""
queryset = TransformedFood.objects.order_by('id')
serializer_class = TransformedFoodSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]

View File

@ -0,0 +1,100 @@
[
{
"model": "food.allergen",
"pk": 1,
"fields": {
"name": "Lait"
}
},
{
"model": "food.allergen",
"pk": 2,
"fields": {
"name": "Oeufs"
}
},
{
"model": "food.allergen",
"pk": 3,
"fields": {
"name": "Gluten"
}
},
{
"model": "food.allergen",
"pk": 4,
"fields": {
"name": "Fruits à coques"
}
},
{
"model": "food.allergen",
"pk": 5,
"fields": {
"name": "Arachides"
}
},
{
"model": "food.allergen",
"pk": 6,
"fields": {
"name": "Sésame"
}
},
{
"model": "food.allergen",
"pk": 7,
"fields": {
"name": "Soja"
}
},
{
"model": "food.allergen",
"pk": 8,
"fields": {
"name": "Céleri"
}
},
{
"model": "food.allergen",
"pk": 9,
"fields": {
"name": "Lupin"
}
},
{
"model": "food.allergen",
"pk": 10,
"fields": {
"name": "Moutarde"
}
},
{
"model": "food.allergen",
"pk": 11,
"fields": {
"name": "Sulfites"
}
},
{
"model": "food.allergen",
"pk": 12,
"fields": {
"name": "Crustacés"
}
},
{
"model": "food.allergen",
"pk": 13,
"fields": {
"name": "Mollusques"
}
},
{
"model": "food.allergen",
"pk": 14,
"fields": {
"name": "Poissons"
}
}
]

View File

@ -3,42 +3,41 @@
from random import shuffle from random import shuffle
from django import forms
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from member.models import Club
from bootstrap_datepicker_plus.widgets import DateTimePickerInput from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from django import forms
from django.forms.widgets import NumberInput
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note_kfet.inputs import Autocomplete from note_kfet.inputs import Autocomplete
from note_kfet.middlewares import get_current_request from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import BasicFood, QRCode, TransformedFood from .models import Food, BasicFood, TransformedFood, QRCode
class AddIngredientForms(forms.ModelForm): class QRCodeForms(forms.ModelForm):
""" """
Form for add an ingredient Form for create QRCode for container
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['ingredient'].queryset = self.fields['ingredient'].queryset.filter( self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
end_of_life__isnull=True,
polymorphic_ctype__model='transformedfood', polymorphic_ctype__model='transformedfood',
is_ready=False, ).filter(PermissionBackend.filter_queryset(
is_active=True, get_current_request(),
was_eaten=False, TransformedFood,
) "view",
# Caution, the logic is inverted here, we flip the logic on saving in AddIngredientView ))
self.fields['is_active'].initial = True
self.fields['is_active'].label = _("Fully used")
class Meta: class Meta:
model = TransformedFood model = QRCode
fields = ('ingredient', 'is_active') fields = ('food_container',)
class BasicFoodForms(forms.ModelForm): class BasicFoodForms(forms.ModelForm):
""" """
Form for add non-transformed food Form for add basicfood
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -51,64 +50,138 @@ class BasicFoodForms(forms.ModelForm):
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all()) clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
shuffle(clubs) shuffle(clubs)
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..." self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs")
class Meta: class Meta:
model = BasicFood model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'is_active', 'was_eaten', 'allergens',) fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
widgets = { widgets = {
"owner": Autocomplete( "owner": Autocomplete(
model=Club, model=Club,
attrs={"api_url": "/api/members/club/"}, attrs={"api_url": "/api/members/club/"},
), ),
'expiry_date': DateTimePickerInput(), "expiry_date": DateTimePickerInput(),
} }
class QRCodeForms(forms.ModelForm):
"""
Form for create QRCode
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
is_active=True,
was_eaten=False,
polymorphic_ctype__model='transformedfood',
)
class Meta:
model = QRCode
fields = ('food_container',)
class TransformedFoodForms(forms.ModelForm): class TransformedFoodForms(forms.ModelForm):
""" """
Form for add transformed food Form for add transformedfood
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
self.fields['name'].required = True self.fields['name'].required = True
self.fields['owner'].required = True self.fields['owner'].required = True
self.fields['creation_date'].required = True
self.fields['creation_date'].initial = timezone.now
self.fields['is_active'].initial = True
self.fields['is_ready'].initial = False
self.fields['was_eaten'].initial = False
# Some example # Some example
self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")}) self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all()) clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
shuffle(clubs) shuffle(clubs)
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..." self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs")
class Meta: class Meta:
model = TransformedFood model = TransformedFood
fields = ('name', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life') fields = ('name', 'owner', 'order',)
widgets = { widgets = {
"owner": Autocomplete( "owner": Autocomplete(
model=Club, model=Club,
attrs={"api_url": "/api/members/club/"}, attrs={"api_url": "/api/members/club/"},
), ),
'creation_date': DateTimePickerInput(),
} }
class BasicFoodUpdateForms(forms.ModelForm):
"""
Form for update basicfood object
"""
class Meta:
model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens')
widgets = {
"owner": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
"expiry_date": DateTimePickerInput(),
}
class TransformedFoodUpdateForms(forms.ModelForm):
"""
Form for update transformedfood object
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['shelf_life'].label = _('Shelf life (in hours)')
class Meta:
model = TransformedFood
fields = ('name', 'owner', 'end_of_life', 'is_ready', 'order', 'shelf_life')
widgets = {
"owner": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
"expiry_date": DateTimePickerInput(),
"shelf_life": NumberInput(),
}
class AddIngredientForms(forms.ModelForm):
"""
Form for add an ingredient
"""
fully_used = forms.BooleanField()
fully_used.initial = True
fully_used.required = False
fully_used.label = _("Fully used")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# TODO find a better way to get pk (be not url scheme dependant)
pk = get_current_request().path.split('/')[-1]
self.fields['ingredients'].queryset = self.fields['ingredients'].queryset.filter(
polymorphic_ctype__model="transformedfood",
is_ready=False,
end_of_life='',
).filter(PermissionBackend.filter_queryset(get_current_request(), TransformedFood, "change")).exclude(pk=pk)
class Meta:
model = TransformedFood
fields = ('ingredients',)
class ManageIngredientsForm(forms.Form):
"""
Form to manage ingredient
"""
fully_used = forms.BooleanField()
fully_used.initial = True
fully_used.required = True
fully_used.label = _('Fully used')
name = forms.CharField()
name.widget = Autocomplete(
model=Food,
resetable=True,
attrs={"api_url": "/api/food/food",
"class": "autocomplete"},
)
name.label = _('Name')
qrcode = forms.IntegerField()
qrcode.widget = Autocomplete(
model=QRCode,
resetable=True,
attrs={"api_url": "/api/food/qrcode/",
"name_field": "qr_code_number",
"class": "autocomplete"},
)
qrcode.label = _('QR code number')
ManageIngredientsFormSet = forms.formset_factory(
ManageIngredientsForm,
extra=1,
)

View File

@ -1,84 +1,199 @@
# Generated by Django 2.2.28 on 2024-07-05 08:57 # Generated by Django 4.2.20 on 2025-04-17 21:43
import datetime
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('contenttypes', '0002_remove_content_type_name'), ("contenttypes", "0002_remove_content_type_name"),
('member', '0011_profile_vss_charter_read'), ("member", "0013_auto_20240801_1436"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Allergen', name="Allergen",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255, verbose_name='name')), "id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, verbose_name="name")),
], ],
options={ options={
'verbose_name': 'Allergen', "verbose_name": "Allergen",
'verbose_name_plural': 'Allergens', "verbose_name_plural": "Allergens",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Food', name="Food",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255, verbose_name='name')), "id",
('expiry_date', models.DateTimeField(verbose_name='expiry date')), models.AutoField(
('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')), auto_created=True,
('is_ready', models.BooleanField(default=False, verbose_name='is ready')), primary_key=True,
('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')), serialize=False,
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='owner')), verbose_name="ID",
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_food.food_set+', to='contenttypes.ContentType')), ),
),
("name", models.CharField(max_length=255, verbose_name="name")),
("expiry_date", models.DateTimeField(verbose_name="expiry date")),
(
"end_of_life",
models.CharField(max_length=255, verbose_name="end of life"),
),
(
"is_ready",
models.BooleanField(max_length=255, verbose_name="is ready"),
),
("order", models.CharField(max_length=255, verbose_name="order")),
(
"allergens",
models.ManyToManyField(
blank=True, to="food.allergen", verbose_name="allergens"
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="member.club",
verbose_name="owner",
),
),
(
"polymorphic_ctype",
models.ForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="polymorphic_%(app_label)s.%(class)s_set+",
to="contenttypes.contenttype",
),
),
], ],
options={ options={
'verbose_name': 'foods', "verbose_name": "Food",
"verbose_name_plural": "Foods",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='BasicFood', name="BasicFood",
fields=[ fields=[
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')), (
('date_type', models.CharField(choices=[('DLC', 'DLC'), ('DDM', 'DDM')], max_length=255)), "food_ptr",
('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')), models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="food.food",
),
),
(
"arrival_date",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="arrival date"
),
),
(
"date_type",
models.CharField(
choices=[("DLC", "DLC"), ("DDM", "DDM")], max_length=255
),
),
], ],
options={ options={
'verbose_name': 'Basic food', "verbose_name": "Basic food",
'verbose_name_plural': 'Basic foods', "verbose_name_plural": "Basic foods",
}, },
bases=('food.food',), bases=("food.food",),
), ),
migrations.CreateModel( migrations.CreateModel(
name='QRCode', name="QRCode",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('qr_code_number', models.PositiveIntegerField(unique=True, verbose_name='QR-code number')), "id",
('food_container', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='QR_code', to='food.Food', verbose_name='food container')), models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"qr_code_number",
models.PositiveIntegerField(
unique=True, verbose_name="qr code number"
),
),
(
"food_container",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="QR_code",
to="food.food",
verbose_name="food container",
),
),
], ],
options={ options={
'verbose_name': 'QR-code', "verbose_name": "QR-code",
'verbose_name_plural': 'QR-codes', "verbose_name_plural": "QR-codes",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='TransformedFood', name="TransformedFood",
fields=[ fields=[
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')), (
('creation_date', models.DateTimeField(verbose_name='creation date')), "food_ptr",
('is_active', models.BooleanField(default=True, verbose_name='is active')), models.OneToOneField(
('ingredient', models.ManyToManyField(blank=True, related_name='transformed_ingredient_inv', to='food.Food', verbose_name='transformed ingredient')), auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="food.food",
),
),
(
"creation_date",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="creation date"
),
),
(
"shelf_life",
models.DurationField(
default=datetime.timedelta(days=3), verbose_name="shelf life"
),
),
(
"ingredients",
models.ManyToManyField(
blank=True,
related_name="transformed_ingredient_inv",
to="food.food",
verbose_name="transformed ingredient",
),
),
], ],
options={ options={
'verbose_name': 'Transformed food', "verbose_name": "Transformed food",
'verbose_name_plural': 'Transformed foods', "verbose_name_plural": "Transformed foods",
}, },
bases=('food.food',), bases=("food.food",),
), ),
] ]

View File

@ -1,19 +0,0 @@
# Generated by Django 2.2.28 on 2024-07-06 20:37
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='transformedfood',
name='shelf_life',
field=models.DurationField(default=datetime.timedelta(days=3), verbose_name='shelf life'),
),
]

View File

@ -1,62 +0,0 @@
from django.db import migrations
def create_14_mandatory_allergens(apps, schema_editor):
"""
There are 14 mandatory allergens, they are pre-injected
"""
Allergen = apps.get_model("food", "allergen")
Allergen.objects.get_or_create(
name="Gluten",
)
Allergen.objects.get_or_create(
name="Fruits à coques",
)
Allergen.objects.get_or_create(
name="Crustacés",
)
Allergen.objects.get_or_create(
name="Céléri",
)
Allergen.objects.get_or_create(
name="Oeufs",
)
Allergen.objects.get_or_create(
name="Moutarde",
)
Allergen.objects.get_or_create(
name="Poissons",
)
Allergen.objects.get_or_create(
name="Soja",
)
Allergen.objects.get_or_create(
name="Lait",
)
Allergen.objects.get_or_create(
name="Sulfites",
)
Allergen.objects.get_or_create(
name="Sésame",
)
Allergen.objects.get_or_create(
name="Lupin",
)
Allergen.objects.get_or_create(
name="Arachides",
)
Allergen.objects.get_or_create(
name="Mollusques",
)
class Migration(migrations.Migration):
dependencies = [
('food', '0002_transformedfood_shelf_life'),
]
operations = [
migrations.RunPython(create_14_mandatory_allergens),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 2.2.28 on 2024-08-13 21:58
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('food', '0003_create_14_allergens_mandatory'),
]
operations = [
migrations.RemoveField(
model_name='transformedfood',
name='is_active',
),
migrations.AddField(
model_name='food',
name='is_active',
field=models.BooleanField(default=True, verbose_name='is active'),
),
migrations.AlterField(
model_name='qrcode',
name='food_container',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='QR_code', to='food.Food', verbose_name='food container'),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('food', '0004_auto_20240813_2358'),
]
operations = [
migrations.AlterField(
model_name='food',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
]

View File

@ -6,37 +6,13 @@ from datetime import timedelta
from django.db import models, transaction from django.db import models, transaction
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from member.models import Club
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from member.models import Club
class QRCode(models.Model):
"""
An QRCode model
"""
qr_code_number = models.PositiveIntegerField(
verbose_name=_("QR-code number"),
unique=True,
)
food_container = models.ForeignKey(
'Food',
on_delete=models.CASCADE,
related_name='QR_code',
verbose_name=_('food container'),
)
class Meta:
verbose_name = _("QR-code")
verbose_name_plural = _("QR-codes")
def __str__(self):
return _("QR-code number {qr_code_number}").format(qr_code_number=self.qr_code_number)
class Allergen(models.Model): class Allergen(models.Model):
""" """
A list of allergen and alimentary restrictions Allergen and alimentary restrictions
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
@ -44,16 +20,19 @@ class Allergen(models.Model):
) )
class Meta: class Meta:
verbose_name = _('Allergen') verbose_name = _("Allergen")
verbose_name_plural = _('Allergens') verbose_name_plural = _("Allergens")
def __str__(self): def __str__(self):
return self.name return self.name
class Food(PolymorphicModel): class Food(PolymorphicModel):
"""
Describe any type of food
"""
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_("name"),
max_length=255, max_length=255,
) )
@ -67,7 +46,7 @@ class Food(PolymorphicModel):
allergens = models.ManyToManyField( allergens = models.ManyToManyField(
Allergen, Allergen,
blank=True, blank=True,
verbose_name=_('allergen'), verbose_name=_('allergens'),
) )
expiry_date = models.DateTimeField( expiry_date = models.DateTimeField(
@ -75,41 +54,69 @@ class Food(PolymorphicModel):
null=False, null=False,
) )
was_eaten = models.BooleanField( end_of_life = models.CharField(
default=False, blank=True,
verbose_name=_('was eaten'), verbose_name=_('end of life'),
max_length=255,
) )
# is_ready != is_active : is_ready signifie que la nourriture est prête à être manger,
# is_active signifie que la nourriture n'est pas encore archivé
# il sert dans les cas où il est plus intéressant que de l'open soit conservé (confiture par ex)
is_ready = models.BooleanField( is_ready = models.BooleanField(
default=False,
verbose_name=_('is ready'), verbose_name=_('is ready'),
max_length=255,
) )
is_active = models.BooleanField( order = models.CharField(
default=True, blank=True,
verbose_name=_('is active'), verbose_name=_('order'),
max_length=255,
) )
def __str__(self): def __str__(self):
return self.name return self.name
@transaction.atomic @transaction.atomic
def save(self, force_insert=False, force_update=False, using=None, update_fields=None): def update_allergens(self):
return super().save(force_insert, force_update, using, update_fields) # update parents
for parent in self.transformed_ingredient_inv.iterator():
old_allergens = list(parent.allergens.all()).copy()
parent.allergens.clear()
for child in parent.ingredients.iterator():
if child.pk != self.pk:
parent.allergens.set(parent.allergens.union(child.allergens.all()))
parent.allergens.set(parent.allergens.union(self.allergens.all()))
if old_allergens != list(parent.allergens.all()):
parent.save(old_allergens=old_allergens)
def update_expiry_date(self):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
old_expiry_date = parent.expiry_date
parent.expiry_date = parent.shelf_life + parent.creation_date
for child in parent.ingredients.iterator():
if (child.pk != self.pk
and not (child.polymorphic_ctype.model == 'basicfood'
and child.date_type == 'DDM')):
parent.expiry_date = min(parent.expiry_date, child.expiry_date)
if self.polymorphic_ctype.model == 'basicfood' and self.date_type == 'DLC':
parent.expiry_date = min(parent.expiry_date, self.expiry_date)
if old_expiry_date != parent.expiry_date:
parent.save()
class Meta: class Meta:
verbose_name = _('food') verbose_name = _('Food')
verbose_name = _('foods') verbose_name_plural = _('Foods')
class BasicFood(Food): class BasicFood(Food):
""" """
Food which has been directly buy on supermarket A basic food is a food directly buy and stored
""" """
arrival_date = models.DateTimeField(
default=timezone.now,
verbose_name=_('arrival date'),
)
date_type = models.CharField( date_type = models.CharField(
max_length=255, max_length=255,
choices=( choices=(
@ -118,50 +125,70 @@ class BasicFood(Food):
) )
) )
arrival_date = models.DateTimeField(
verbose_name=_('arrival date'),
default=timezone.now,
)
# label = models.ImageField(
# verbose_name=_('food label'),
# max_length=255,
# blank=False,
# null=False,
# upload_to='label/',
# )
@transaction.atomic @transaction.atomic
def update_allergens(self): def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
# update parents created = self.pk is None
for parent in self.transformed_ingredient_inv.iterator(): if not created:
parent.update_allergens() # Check if important fields are updated
old_food = Food.objects.select_for_update().get(pk=self.pk)
if not hasattr(self, "_force_save"):
# Allergens
@transaction.atomic if ('old_allergens' in kwargs
def update_expiry_date(self): and list(self.allergens.all()) != kwargs['old_allergens']):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
parent.update_expiry_date()
@transaction.atomic
def update(self):
self.update_allergens() self.update_allergens()
# Expiry date
if ((self.expiry_date != old_food.expiry_date
and self.date_type == 'DLC')
or old_food.date_type != self.date_type):
self.update_expiry_date() self.update_expiry_date()
return super().save(force_insert, force_update, using, update_fields)
@staticmethod
def get_lastests_objects(number, distinct_field, order_by_field):
"""
Get the last object with distinct field and ranked with order_by
This methods exist because we can't distinct with one field and
order with another
"""
foods = BasicFood.objects.order_by(order_by_field).all()
field = []
for food in foods:
if getattr(food, distinct_field) in field:
continue
else:
field.append(getattr(food, distinct_field))
number -= 1
yield food
if not number:
return
class Meta: class Meta:
verbose_name = _('Basic food') verbose_name = _('Basic food')
verbose_name_plural = _('Basic foods') verbose_name_plural = _('Basic foods')
def __str__(self):
return self.name
class TransformedFood(Food): class TransformedFood(Food):
""" """
Transformed food are a mix between basic food and meal A transformed food is a food with ingredients
""" """
creation_date = models.DateTimeField( creation_date = models.DateTimeField(
default=timezone.now,
verbose_name=_('creation date'), verbose_name=_('creation date'),
) )
ingredient = models.ManyToManyField( # Without microbiological analyzes, the storage time is 3 days
shelf_life = models.DurationField(
default=timedelta(days=3),
verbose_name=_('shelf life'),
)
ingredients = models.ManyToManyField(
Food, Food,
blank=True, blank=True,
symmetrical=False, symmetrical=False,
@ -169,58 +196,91 @@ class TransformedFood(Food):
verbose_name=_('transformed ingredient'), verbose_name=_('transformed ingredient'),
) )
# Without microbiological analyzes, the storage time is 3 days def check_cycle(self, ingredients, origin, checked):
shelf_life = models.DurationField( for ingredient in ingredients:
verbose_name=_("shelf life"), if ingredient == origin:
default=timedelta(days=3), # We break the cycle
) self.ingredients.remove(ingredient)
if ingredient.polymorphic_ctype.model == 'transformedfood' and ingredient not in checked:
ingredient.check_cycle(ingredient.ingredients.all(), origin, checked)
checked.append(ingredient)
@transaction.atomic @transaction.atomic
def archive(self): def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
# When a meal are archived, if it was eaten, update ingredient fully used for this meal created = self.pk is None
raise NotImplementedError if not created:
# Check if important fields are updated
update = {'allergens': False, 'expiry_date': False}
old_food = Food.objects.select_for_update().get(pk=self.pk)
if not hasattr(self, "_force_save"):
# Allergens
# Unfortunately with the many-to-many relation we can't access
# to old allergens
if ('old_allergens' in kwargs
and list(self.allergens.all()) != kwargs['old_allergens']):
update['allergens'] = True
@transaction.atomic # Expiry date
def update_allergens(self): update['expiry_date'] = (self.shelf_life != old_food.shelf_life
# When allergens are changed, simply update the parents' allergens or self.creation_date != old_food.creation_date)
old_allergens = list(self.allergens.all()) if update['expiry_date']:
self.allergens.clear()
for ingredient in self.ingredient.iterator():
self.allergens.set(self.allergens.union(ingredient.allergens.all()))
if old_allergens == list(self.allergens.all()):
return
super().save()
# update parents
for parent in self.transformed_ingredient_inv.iterator():
parent.update_allergens()
@transaction.atomic
def update_expiry_date(self):
# When expiry_date is changed, simply update the parents' expiry_date
old_expiry_date = self.expiry_date
self.expiry_date = self.creation_date + self.shelf_life self.expiry_date = self.creation_date + self.shelf_life
for ingredient in self.ingredient.iterator(): # Unfortunately with the set method ingredients are already save,
self.expiry_date = min(self.expiry_date, ingredient.expiry_date) # we check cycle after if possible
if ('old_ingredients' in kwargs
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
update['allergens'] = True
update['expiry_date'] = True
if old_expiry_date == self.expiry_date: # it's preferable to keep a queryset but we allow list too
return if type(kwargs['old_ingredients']) is list:
super().save() kwargs['old_ingredients'] = Food.objects.filter(
pk__in=[food.pk for food in kwargs['old_ingredients']])
# update parents self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
for parent in self.transformed_ingredient_inv.iterator(): if update['allergens']:
parent.update_expiry_date()
@transaction.atomic
def update(self):
self.update_allergens() self.update_allergens()
if update['expiry_date']:
self.update_expiry_date() self.update_expiry_date()
@transaction.atomic if created:
def save(self, *args, **kwargs): self.expiry_date = self.shelf_life + self.creation_date
super().save(*args, **kwargs)
# We save here because we need pk for many-to-many relation
super().save(force_insert, force_update, using, update_fields)
for child in self.ingredients.iterator():
self.allergens.set(self.allergens.union(child.allergens.all()))
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
self.expiry_date = min(self.expiry_date, child.expiry_date)
return super().save(force_insert, force_update, using, update_fields)
class Meta: class Meta:
verbose_name = _('Transformed food') verbose_name = _('Transformed food')
verbose_name_plural = _('Transformed foods') verbose_name_plural = _('Transformed foods')
def __str__(self):
return self.name
class QRCode(models.Model):
"""
QR-code for register food
"""
qr_code_number = models.PositiveIntegerField(
unique=True,
verbose_name=_('qr code number'),
)
food_container = models.ForeignKey(
Food,
on_delete=models.CASCADE,
related_name='QR_code',
verbose_name=_('food container'),
)
class Meta:
verbose_name = _('QR-code')
verbose_name_plural = _('QR-codes')
def __str__(self):
return _('QR-code number') + ' ' + str(self.qr_code_number)

View File

@ -2,18 +2,20 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables import django_tables2 as tables
from django_tables2 import A
from .models import TransformedFood from .models import Food
class TransformedFoodTable(tables.Table): class FoodTable(tables.Table):
name = tables.LinkColumn( """
'food:food_view', List all foods.
args=[A('pk'), ], """
)
class Meta: class Meta:
model = TransformedFood model = Food
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('name', "owner", "allergens", "expiry_date") fields = ('name', 'owner', 'allergens', 'expiry_date')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk),
'style': 'cursor:pointer',
}

View File

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -1,37 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ food.name }}
</h3>
<div class="card-body">
<ul>
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
<li><p>{% trans 'Arrival date' %} : {{ food.arrival_date }}</p></li>
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }} ({{ food.date_type }})</p></li>
<li>{% trans 'Allergens' %} :</li>
<ul>
{% for allergen in food.allergens.iterator %}
<li>{{ allergen.name }}</li>
{% endfor %}
</ul>
<p>
<li><p>{% trans 'Active' %} : {{ food.is_active }}<p></li>
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}<p></li>
</ul>
{% if can_update %}
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=food.pk %}">{% trans 'Update' %}</a>
{% endif %}
{% if can_add_ingredient %}
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans 'Add to a meal' %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -1,55 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<a class="btn btn-sm btn-success" href="{% url "food:qrcode_basic_create" slug=slug %}">
{% trans 'New basic food' %}
</a>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
<div class="card-body" id="profile_infos">
<h4>{% trans "Copy constructor" %}</h4>
<table class="table">
<thead>
<tr>
<th class="orderable">
{% trans "Name" %}
</th>
<th class="orderable">
{% trans "Owner" %}
</th>
<th class="orderable">
{% trans "Arrival date" %}
</th>
<th class="orderable">
{% trans "Expiry date" %}
</th>
</tr>
</thead>
<tbody>
{% for basic in last_basic %}
<tr>
<td><a href="{% url "food:qrcode_basic_create" slug=slug %}?copy={{ basic.pk }}">{{ basic.name }}</a></td>
<td>{{ basic.owner }}</td>
<td>{{ basic.arrival_date }}</td>
<td>{{ basic.expiry_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ food.name }}
</h3>
<div class="card-body">
<ul>
{% for field, value in fields %}
<li> {{ field }} : {{ value }}</li>
{% endfor %}
{% if meals %}
<li> {% trans "Contained in" %} :
{% for meal in meals %}
<a href="{% url "food:transformedfood_view" pk=meal.pk %}">{{ meal.name }}</a>{% if not forloop.last %},{% endif %}
{% endfor %}
</li>
{% endif %}
{% if foods %}
<li> {% trans "Contain" %} :
{% for food in foods %}
<a href="{% url "food:food_view" pk=food.pk %}">{{ food.name }}</a>{% if not forloop.last %},{% endif %}
{% endfor %}
</li>
{% endif %}
</ul>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "food:food_update" pk=food.pk %}">
{% trans "Update" %}
</a>
{% endif %}
{% if add_ingredient %}
<a class="btn btn-sm btn-primary" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans "Add to a meal" %}
</a>
{% endif %}
{% if manage_ingredients %}
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
{% trans "Manage ingredients" %}
</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}
</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends "base_search.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
{{ block.super }}
<br>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Meal served" %}
</h3>
{% if can_add_meal %}
<div class="card-footer">
<a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}">
{% trans "New meal" %}
</a>
</div>
{% endif %}
{% if served.data %}
{% render_table served %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no meal served." %}
</div>
</div>
</div>
{% endif %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Free food" %}
</h3>
{% if open.data %}
{% render_table open %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no free food." %}
</div>
</div>
{% endif %}
</div>
{% if club_tables %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Food of your clubs" %}
</h3>
</div>
{% for table in club_tables %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Food of club" %} {{ table.prefix }}
</h3>
{% if table.data %}
{% render_table table %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "Yours club has not food yet." %}
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% comment %} {% comment %}
Copyright (C) by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load i18n crispy_forms_tags %} {% load i18n crispy_forms_tags %}

View File

@ -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 %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form"></div>
<form method="post" action="">
{% csrf_token %}
<table class="table table-condensed table-striped">
{# Fill initial data #}
{% for display, form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{{ form.name.label }}</th>
<th>{{ form.qrcode.label }}</th>
<th>{{ form.fully_used.label }}</th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
{% if display %}
<tr class="row-formset ingredients">
{% else %}
<tr class="row-formset ingredients" style="display: none">
{% endif %}
<td>{{ form.name }}</td>
<td>{{ form.qrcode }}</td>
<td>{{ form.fully_used }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Display buttons to add and remove ingredients #}
<div class="card-body">
<div class="btn-group btn-block" role="group">
<button type="button" id="add_more" class="btn btn-success">{% trans "Add ingredient" %}</button>
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove ingredient" %}</button>
</div>
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</div>
</form>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
const foods = {{ ingredients | safe }};
function set_ingredient_id () {
let ingredients = document.getElementsByClassName('ingredients');
for (var i = 0; i < ingredients.length; i++) {
ingredients[i].id = 'ingredients-' + parseInt(i);
};
}
set_ingredient_id();
function prepopulate () {
for (var i = 0; i < {{ ingredients_count }}; i++) {
let prefix = 'id_form-' + parseInt(i) + '-';
document.getElementById(prefix + 'name_pk').value = parseInt(foods[i]['food_pk']);
document.getElementById(prefix + 'name').value = foods[i]['food_name'];
document.getElementById(prefix + 'qrcode_pk').value = parseInt(foods[i]['qr_pk']);
if (foods[i]['qr_number'] === '') {
document.getElementById(prefix + 'qrcode').value = '';
}
else {
document.getElementById(prefix + 'qrcode').value = parseInt(foods[i]['qr_number']);
};
document.getElementById(prefix + 'fully_used').checked = Boolean(foods[i]['fully_used']);
};
}
prepopulate();
function delete_form_data (form_id) {
let prefix = "id_form-" + parseInt(form_id) + "-";
document.getElementById(prefix + "name_pk").value = "";
document.getElementById(prefix + "name").value = "";
document.getElementById(prefix + "qrcode_pk").value = "";
document.getElementById(prefix + "qrcode").value = "";
document.getElementById(prefix + "fully_used").checked = true;
}
var form_count = {{ ingredients_count }} + 1;
$('#add_more').click(function () {
let ingredient_form = document.getElementById('ingredients-' + parseInt(form_count));
if (ingredient_form === null) {
addMsg(gettext("You can't add more ingredient"), "danger", 5000);
return;};
ingredient_form.style = "display: true";
form_count += 1;
});
$('#remove_one').click(function () {
let ingredient_form = document.getElementById('ingredients-' + parseInt(form_count - 1));
if (ingredient_form === null) {
return;};
ingredient_form.style = "display: none";
delete_form_data(form_count - 1);
form_count -= 1;
});
addMsg(gettext("Add ingredient with their name or their qrcode, if two different priority is given to qrcode"), "warning");
</script>
{% endblock %}

View File

@ -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 %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
<div class="card-body">
<h4>
{% trans "Copy constructor" %}
<a class="btn btn-secondary" href="{% url "food:basicfood_create" slug=slug %}">{% trans "New food" %}</a>
</h4>
<table class="table">
<thead>
<tr>
<th class="orderable">
{% trans "Name" %}
</th>
<th class="orderable">
{% trans "Owner" %}
</th>
<th class="orderable">
{% trans "Expiry date" %}
</th>
</tr>
</thead>
<tbody>
{% for food in last_items %}
<tr>
<td><a href="{% url "food:basicfood_create" slug=slug %}?copy={{ food.pk }}">{{ food.name }}</a></td>
<td>{{ food.owner }}</td>
<td>{{ food.expiry_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,39 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {% trans 'number' %} {{ qrcode.qr_code_number }}
</h3>
<div class="card-body">
<ul>
<li><p>{% trans 'Name' %} : {{ qrcode.food_container.name }}</p></li>
<li><p>{% trans 'Owner' %} : {{ qrcode.food_container.owner }}</p></li>
<li><p>{% trans 'Expiry date' %} : {{ qrcode.food_container.expiry_date }}</p></li>
</ul>
{% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %}
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=qrcode.food_container.pk %}" data-turbolinks="false">
{% trans 'Update' %}
</a>
{% elif can_update_transformed %}
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=qrcode.food_container.pk %}">
{% trans 'Update' %}
</a>
{% endif %}
{% if can_view_detail %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_view" pk=qrcode.food_container.pk %}">
{% trans 'View details' %}
</a>
{% endif %}
{% if can_add_ingredient %}
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=qrcode.food_container.pk %}">
{% trans 'Add to a meal' %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -1,51 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ food.name }}
</h3>
<div class="card-body">
<ul>
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
{% if can_see_ready %}
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
{% endif %}
<li><p>{% trans 'Creation date' %} : {{ food.creation_date }}</p></li>
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }}</p></li>
<li>{% trans 'Allergens' %} :</li>
<ul>
{% for allergen in food.allergens.iterator %}
<li>{{ allergen.name }}</li>
{% endfor %}
</ul>
<p>
<li>{% trans 'Ingredients' %} :</li>
<ul>
{% for ingredient in food.ingredient.iterator %}
<li><a href="{% url "food:food_view" pk=ingredient.pk %}">{{ ingredient.name }}</a></li>
{% endfor %}
</ul>
<p>
<li><p>{% trans 'Shelf life' %} : {{ food.shelf_life }}</p></li>
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
<li><p>{% trans 'Active' %} : {{ food.is_active }}</p></li>
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}</p></li>
</ul>
{% if can_update %}
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=food.pk %}">
{% trans 'Update' %}
</a>
{% endif %}
{% if can_add_ingredient %}
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans 'Add to a meal' %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Meal served" %}
</h3>
{% if can_create_meal %}
<div class="card-footer">
<a class="btn btn-sm btn-success" href="{% url 'food:transformed_create' %}" data-turbolinks="false">
{% trans 'New meal' %}
</a>
</div>
{% endif %}
{% if served.data %}
{% render_table served %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no meal served." %}
</div>
</div>
{% endif %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Open" %}
</h3>
{% if open.data %}
{% render_table open %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no free meal." %}
</div>
</div>
{% endif %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "All meals" %}
</h3>
{% if table.data %}
{% render_table table %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no meal." %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -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 %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<table class="table table-condensed table-striped">
{# Fill initial data #}
{% for ingredient_form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "QR-code number" %}</th>
<th>{% trans "Fully used" %}<th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
<tr class="row-formset">
{{ ingredient_form | crispy }}
<td>{{ ingredient_form.name }}</td>
<td>{{ ingredient_form.qrcode }}</td>
<td>{{ ingredient_form.fully_used }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Display buttons to add and remove products #}
<div class="card-body">
<div class="btn-group btn-block" role="group">
<button type="button" id="add_more" class="btn btn-success">{% trans "Add ingredient" %}</button>
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove ingredient" %}</button>
</div>
<button type="submit" class="btn btn-block btn-primary">{% trans "Submit" %}</button>
</div>
</form>
</div>
</div>
{# Hidden div that store an empty product form, to be copied into new forms #}
<div id="empty_form" style="display: none;">
<table class='no_error'>
<tbody id="for_real">
<tr class="row-formset">
<td>{{ formset.empty_form.name }}</td>
<td>{{ formset.empty_form.qrcode }}</td>
<td>{{ formset.empty_form.fully_used }}</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
IDS = {};
$("#id_form-TOTAL_FORMS").val($(".row-formset").length - 1);
$('#add_more').click(function () {
let form_idx = $('#id_form-TOTAL_FORMS').val();
$('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx));
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
$('#id_form-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]);
});
$('#remove_one').click(function () {
let form_idx = $('#id_form-TOTAL_FORMS').val();
if (form_idx > 0) {
IDS[parseInt(form_idx) - 1] = $('#id_form-' + (parseInt(form_idx) - 1) + '-id').val();
$('#form_body tr:last-child').remove();
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) - 1);
}
});
</script>
{% endblock %}

View File

@ -1,3 +0,0 @@
# from django.test import TestCase
# Create your tests here.

View File

@ -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/')

View File

@ -8,14 +8,14 @@ from . import views
app_name = 'food' app_name = 'food'
urlpatterns = [ urlpatterns = [
path('', views.TransformedListView.as_view(), name='food_list'), path('', views.FoodListView.as_view(), name='food_list'),
path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'), path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'), path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'), path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'), path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'), path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'), path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'), path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'), path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
] ]

53
apps/food/utils.py Normal file
View File

@ -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]

View File

@ -1,421 +1,508 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.db import transaction from datetime import timedelta
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect from api.viewsets import is_regex
from django_tables2.views import MultiTableMixin from django_tables2.views import MultiTableMixin
from django.urls import reverse from django.db import transaction
from django.utils.translation import gettext_lazy as _ from django.db.models import Q
from django.utils import timezone from django.http import HttpResponseRedirect, Http404
from django.views.generic import DetailView, UpdateView from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.forms import HiddenInput 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.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 Food, BasicFood, TransformedFood, QRCode
from .models import BasicFood, Food, QRCode, TransformedFood from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
from .tables import TransformedFoodTable 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 model = Food
template_name = 'food/add_ingredient_form.html' tables = [FoodTable, FoodTable, FoodTable, ]
extra_context = {"title": _("Add the ingredient")} extra_context = {"title": _('Food')}
form_class = AddIngredientForms template_name = 'food/food_list.html'
def get_context_data(self, **kwargs): def get_queryset(self, **kwargs):
context = super().get_context_data(**kwargs) return super().get_queryset(**kwargs).distinct()
context["pk"] = self.kwargs["pk"]
return context
@transaction.atomic def get_tables(self):
def form_valid(self, form): bureau_role_pk = 4
form.instance.creater = self.request.user clubs = Club.objects.filter(membership__in=Membership.objects.filter(
food = Food.objects.get(pk=self.kwargs['pk']) user=self.request.user, roles=bureau_role_pk).filter(
add_ingredient_form = AddIngredientForms(data=self.request.POST) date_end__gte=timezone.now()))
if food.is_ready:
form.add_error(None, _("The product is already prepared"))
return self.form_invalid(form)
if not add_ingredient_form.is_valid():
return self.form_invalid(form)
# We flip logic ""fully used = not is_active"" tables = [FoodTable] * (clubs.count() + 3)
food.is_active = not food.is_active self.tables = tables
# Save the aliment and the allergens associed tables = super().get_tables()
for transformed_pk in self.request.POST.getlist('ingredient'): tables[0].prefix = 'search-'
transformed = TransformedFood.objects.get(pk=transformed_pk) tables[1].prefix = 'open-'
if not transformed.is_ready: tables[2].prefix = 'served-'
transformed.ingredient.add(food) for i in range(clubs.count()):
transformed.update() tables[i + 3].prefix = clubs[i].name
food.save() 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): # check regex
return reverse('food:food_list') valid_regex = is_regex(pattern)
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else ''
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}))
""" else:
A view to update a basic food qs = qs.none()
""" search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
model = BasicFood # table open
form_class = BasicFoodForms open_table = self.get_queryset().order_by('expiry_date').filter(
template_name = 'food/basicfood_form.html' Q(polymorphic_ctype__model='transformedfood')
extra_context = {"title": _("Update an aliment")} | Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
expiry_date__lt=timezone.now(), end_of_life='').filter(
@transaction.atomic PermissionBackend.filter_queryset(self.request, Food, 'view'))
def form_valid(self, form): # table served
form.instance.creater = self.request.user served_table = self.get_queryset().order_by('-pk').filter(
basic_food_form = BasicFoodForms(data=self.request.POST) end_of_life='', is_ready=True).exclude(
if not basic_food_form.is_valid(): Q(polymorphic_ctype__model='basicfood',
return self.form_invalid(form) basicfood__date_type='DLC',
expiry_date__lte=timezone.now(),)
ans = super().form_valid(form) | Q(polymorphic_ctype__model='transformedfood',
form.instance.update() expiry_date__lte=timezone.now(),
return ans ))
# tables club
def get_success_url(self, **kwargs): bureau_role_pk = 4
self.object.refresh_from_db() clubs = Club.objects.filter(membership__in=Membership.objects.filter(
return reverse('food:food_view', kwargs={"pk": self.object.pk}) user=self.request.user, roles=bureau_role_pk).filter(
date_end__gte=timezone.now()))
def get_context_data(self, **kwargs): club_table = []
context = super().get_context_data(**kwargs) for club in clubs:
return context 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 FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): ))
""" return [search_table, open_table, served_table] + club_table
A view to see a food
"""
model = Food
extra_context = {"title": _("Details of:")}
context_object_name = "food"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food") tables = context['tables']
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood") # 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 return context
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): class QRCodeCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
#####################################################################
# TO DO
# - this feature is very pratical for meat or fish, nevertheless we can implement this later
# - fix picture save
# - implement solution crop and convert image (reuse or recode ImageForm from members apps)
#####################################################################
""" """
A view to add a basic food with a qrcode A view to add qrcode
"""
model = BasicFood
form_class = BasicFoodForms
template_name = 'food/basicfood_form.html'
extra_context = {"title": _("Add a new basic food with QRCode")}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
basic_food_form = BasicFoodForms(data=self.request.POST)
if not basic_food_form.is_valid():
return self.form_invalid(form)
# Save the aliment and the allergens associed
basic_food = form.save(commit=False)
# We assume the date of labeling and the same as the date of arrival
basic_food.arrival_date = timezone.now()
basic_food.is_ready = False
basic_food.is_active = True
basic_food.was_eaten = False
basic_food._force_save = True
basic_food.save()
basic_food.refresh_from_db()
qrcode = QRCode()
qrcode.qr_code_number = self.kwargs['slug']
qrcode.food_container = basic_food
qrcode.save()
return super().form_valid(form)
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
def get_sample_object(self):
# We choose a club which may work or BDE else
owner_id = 1
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id)
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
owner_id = club_id
return BasicFood(
name="",
expiry_date=timezone.now(),
owner_id=owner_id,
)
def get_context_data(self, **kwargs):
# Some field are hidden on create
context = super().get_context_data(**kwargs)
form = context['form']
form.fields['is_active'].widget = HiddenInput()
form.fields['was_eaten'].widget = HiddenInput()
copy = self.request.GET.get('copy', None)
if copy is not None:
basic = BasicFood.objects.get(pk=copy)
for field in ['date_type', 'expiry_date', 'name', 'owner']:
form.fields[field].initial = getattr(basic, field)
for field in ['allergens']:
form.fields[field].initial = getattr(basic, field).all()
return context
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
A view to add a new qrcode
""" """
model = QRCode model = QRCode
template_name = 'food/create_qrcode_form.html' template_name = 'food/qrcode.html'
form_class = QRCodeForms form_class = QRCodeForms
extra_context = {"title": _("Add a new QRCode")} extra_context = {"title": _("Add a new QRCode")}
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
qrcode = kwargs["slug"] qrcode = kwargs["slug"]
if self.model.objects.filter(qr_code_number=qrcode).count() > 0: 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: else:
return super().get(*args, **kwargs) 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 @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
form.instance.creater = self.request.user
qrcode_food_form = QRCodeForms(data=self.request.POST) qrcode_food_form = QRCodeForms(data=self.request.POST)
if not qrcode_food_form.is_valid(): if not qrcode_food_form.is_valid():
return self.form_invalid(form) return self.form_invalid(form)
# Save the qrcode
qrcode = form.save(commit=False) qrcode = form.save(commit=False)
qrcode.qr_code_number = self.kwargs["slug"] qrcode.qr_code_number = self.kwargs['slug']
qrcode._force_save = True qrcode._force_save = True
qrcode.save() qrcode.save()
qrcode.refresh_from_db() 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) return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
self.object.refresh_from_db() 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): def get_context_data(self, *args, **kwargs):
return QRCode( context = super().get_context_data(*args, **kwargs)
qr_code_number=self.kwargs["slug"],
food_container_id=1
)
copy = self.request.GET.get('copy', None)
class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): if copy is not None:
""" food = BasicFood.objects.get(pk=copy)
A view to see a qrcode print(context['form'].fields)
""" for field in context['form'].fields:
model = QRCode if field == 'allergens':
extra_context = {"title": _("QRCode")} context['form'].fields[field].initial = getattr(food, field).all()
context_object_name = "qrcode"
slug_field = "qr_code_number"
def get(self, *args, **kwargs):
qrcode = kwargs["slug"]
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
return super().get(*args, **kwargs)
else: else:
return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs)) context['form'].fields[field].initial = getattr(food, field)
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 return context
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
""" """
A view to add a tranformed food A view to add transformedfood
""" """
model = TransformedFood model = TransformedFood
template_name = 'food/transformedfood_form.html'
form_class = TransformedFoodForms form_class = TransformedFoodForms
extra_context = {"title": _("Add a new meal")} extra_context = {"title": _("Add a meal")}
template_name = "food/food_update.html"
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
transformed_food_form = TransformedFoodForms(data=self.request.POST)
if not transformed_food_form.is_valid():
return self.form_invalid(form)
# Save the aliment and allergens associated
transformed_food = form.save(commit=False)
transformed_food.expiry_date = transformed_food.creation_date
transformed_food.is_active = True
transformed_food.is_ready = False
transformed_food.was_eaten = False
transformed_food._force_save = True
transformed_food.save()
transformed_food.refresh_from_db()
ans = super().form_valid(form)
transformed_food.update()
return ans
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:food_view', kwargs={"pk": self.object.pk})
def get_sample_object(self): def get_sample_object(self):
# We choose a club which may work or BDE else # We choose a club which may work or BDE else
owner_id = 1 food = TransformedFood(
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = TransformedFood(name="",
creation_date=timezone.now(),
expiry_date=timezone.now(),
owner_id=club_id)
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
owner_id = club_id
break
return TransformedFood(
name="", name="",
owner_id=owner_id, owner_id=1,
creation_date=timezone.now(),
expiry_date=timezone.now(), expiry_date=timezone.now(),
is_ready=True,
) )
def get_context_data(self, **kwargs): for membership in self.request.user.memberships.all():
context = super().get_context_data(**kwargs) 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 return food
form = context['form']
form.fields['is_active'].widget = HiddenInput()
form.fields['is_ready'].widget = HiddenInput()
form.fields['was_eaten'].widget = HiddenInput()
form.fields['shelf_life'].widget = HiddenInput()
return context @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 model = TransformedFood
template_name = 'food/transformedfood_form.html' fields = ['ingredients']
form_class = TransformedFoodForms extra_context = {"title": _("Manage ingredients of:")}
extra_context = {'title': _('Update a meal')} 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 @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
form.instance.creater = self.request.user form.instance.creater = self.request.user
transformedfood_form = TransformedFoodForms(data=self.request.POST) food = Food.objects.get(pk=self.kwargs['pk'])
if not transformedfood_form.is_valid(): old_allergens = list(food.allergens.all()).copy()
return self.form_invalid(form)
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) 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 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): def get_success_url(self, **kwargs):
self.object.refresh_from_db() 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**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()]
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 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
fields = ['arrival_date', 'date_type']
# We choose a club which should work for field in fields:
for membership in self.request.user.memberships.all(): context["fields"].append((
club_id = membership.club.id BasicFood._meta.get_field(field).verbose_name.capitalize(),
food = TransformedFood( getattr(self.object, field)
name="", ))
owner_id=club_id,
creation_date=timezone.now(),
expiry_date=timezone.now(),
)
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
context['can_create_meal'] = True
break
tables = context["tables"]
for name, table in zip(["table", "open", "served"], tables):
context[name] = table
return context 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)

View File

@ -23,7 +23,7 @@ from .models import Profile, Club, Membership
class CustomAuthenticationForm(AuthenticationForm): class CustomAuthenticationForm(AuthenticationForm):
permission_mask = forms.ModelChoiceField( permission_mask = forms.ModelChoiceField(
label=_("Permission mask"), label=_("Permission mask"),
queryset=PermissionMask.objects.order_by("rank"), queryset=PermissionMask.objects.order_by("-rank"),
empty_label=None, empty_label=None,
) )

View File

@ -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),
]

View File

@ -20,11 +20,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
</form> </form>
</div> </div>
<!-- MODAL TO CROP THE IMAGE --> <!-- MODAL TO CROP THE IMAGE -->
<div class="modal fade" id="modalCrop"> <div class="modal fade" id="modalCrop" data-backdrop="static">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-body"> <div class="modal-body-wrapper" style="width: 500px; height: 500px; padding: 16px;">
<img src="" id="modal-image" style="max-width: 100%;"> <div class="modal-body" style="width: 100%; height: 100%; padding: 0">
<img src="" id="modal-image" style="display: block; max-width: 100%;">
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div class="btn-group pull-left" role="group"> <div class="btn-group pull-left" role="group">

View File

@ -89,7 +89,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ul> </ul>
<div class="card-body"> <div class="card-body">
<select id="debit_type" class="form-control custom-select d-none"> <select id="debit_type" class="form-control custom-select d-none">
{% for special_type in special_types %} {% for special_type in special_types|slice:"::-1" %}
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option> <option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %} {% endfor %}
</select> </select>

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from activity.models import Activity from activity.models import Activity
from member.models import Club, Membership from member.models import Club, Membership
from note.models import NoteUser from note.models import NoteUser, NoteClub
from wei.models import WEIClub, Bus, WEIRegistration from wei.models import WEIClub, Bus, WEIRegistration
@ -122,10 +122,13 @@ class TestPermissionDenied(TestCase):
def test_validate_weiregistration(self): def test_validate_weiregistration(self):
wei = WEIClub.objects.create( wei = WEIClub.objects.create(
name="WEI Test",
membership_start=date.today(), membership_start=date.today(),
date_start=date.today() + timedelta(days=1), date_start=date.today() + timedelta(days=1),
date_end=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") 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))) response = self.client.get(reverse("wei:validate_registration", kwargs=dict(pk=registration.pk)))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.20 on 2025-04-14 20:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('treasury', '0009_alter_sogecredit_transactions'),
]
operations = [
migrations.AlterField(
model_name='invoice',
name='bde',
field=models.CharField(choices=[('Diolistos', 'Diol[list]os'), ('RavePartlist', 'RavePart[list]'), ('SecretStorlist', 'SecretStor[list]'), ('TotalistSpies', 'Tota[list]Spies'), ('Saperlistpopette', 'Saper[list]popette'), ('Finalist', 'Fina[list]'), ('Listorique', '[List]orique'), ('Satellist', 'Satel[list]'), ('Monopolist', 'Monopo[list]'), ('Kataclist', 'Katac[list]')], default='Diolistos', max_length=32, verbose_name='BDE'),
),
]

View File

@ -27,8 +27,9 @@ class Invoice(models.Model):
bde = models.CharField( bde = models.CharField(
max_length=32, max_length=32,
default='RavePartlist', default='Diolistos',
choices=( choices=(
('Diolistos', 'Diol[list]os'),
('RavePartlist', 'RavePart[list]'), ('RavePartlist', 'RavePart[list]'),
('SecretStorlist', 'SecretStor[list]'), ('SecretStorlist', 'SecretStor[list]'),
('TotalistSpies', 'Tota[list]Spies'), ('TotalistSpies', 'Tota[list]Spies'),

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

View File

@ -1,10 +1,11 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, WEIMembershipForm, BusForm, BusTeamForm from .registration import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, WEIMembership1AForm, \
WEIMembershipForm, BusForm, BusTeamForm
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
__all__ = [ __all__ = [
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm', 'WEIForm', 'WEIRegistrationForm', 'WEIRegistration1AForm', 'WEIRegistration2AForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey', 'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
] ]

View File

@ -24,6 +24,7 @@ class WEIForm(forms.ModelForm):
"membership_end": DatePickerInput(), "membership_end": DatePickerInput(),
"date_start": DatePickerInput(), "date_start": DatePickerInput(),
"date_end": DatePickerInput(), "date_end": DatePickerInput(),
"caution_amount": AmountInput(),
} }
@ -39,7 +40,11 @@ class WEIRegistrationForm(forms.ModelForm):
class Meta: class Meta:
model = WEIRegistration 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', 'caution_check'
]
widgets = { widgets = {
"user": Autocomplete( "user": Autocomplete(
User, User,
@ -49,11 +54,30 @@ class WEIRegistrationForm(forms.ModelForm):
'placeholder': 'Nom ...', 'placeholder': 'Nom ...',
}, },
), ),
"birth_date": DatePickerInput(options={'minDate': '1900-01-01', "birth_date": DatePickerInput(options={
'maxDate': '2100-01-01'}), 'minDate': '1900-01-01',
'maxDate': '2100-01-01'
}),
"caution_check": forms.BooleanField(
required=False,
),
} }
class WEIRegistration2AForm(WEIRegistrationForm):
class Meta(WEIRegistrationForm.Meta):
fields = WEIRegistrationForm.Meta.fields + ['caution_type']
widgets = WEIRegistrationForm.Meta.widgets.copy()
widgets.update({
"caution_type": forms.RadioSelect(),
})
class WEIRegistration1AForm(WEIRegistrationForm):
class Meta(WEIRegistrationForm.Meta):
fields = WEIRegistrationForm.Meta.fields
class WEIChooseBusForm(forms.Form): class WEIChooseBusForm(forms.Form):
bus = forms.ModelMultipleChoiceField( bus = forms.ModelMultipleChoiceField(
queryset=Bus.objects, queryset=Bus.objects,
@ -72,7 +96,7 @@ class WEIChooseBusForm(forms.Form):
) )
roles = forms.ModelMultipleChoiceField( roles = forms.ModelMultipleChoiceField(
queryset=WEIRole.objects.filter(~Q(name="1A")), queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")),
label=_("WEI Roles"), label=_("WEI Roles"),
help_text=_("Select the roles that you are interested in."), help_text=_("Select the roles that you are interested in."),
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(), initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
@ -81,13 +105,8 @@ class WEIChooseBusForm(forms.Form):
class WEIMembershipForm(forms.ModelForm): class WEIMembershipForm(forms.ModelForm):
caution_check = forms.BooleanField(
required=False,
label=_("Caution check given"),
)
roles = forms.ModelMultipleChoiceField( roles = forms.ModelMultipleChoiceField(
queryset=WEIRole.objects, queryset=WEIRole.objects.filter(~Q(name="GC WEI")),
label=_("WEI Roles"), label=_("WEI Roles"),
widget=CheckboxSelectMultiple(), widget=CheckboxSelectMultiple(),
) )
@ -194,3 +213,4 @@ class BusTeamForm(forms.ModelForm):
), ),
"color": ColorWidget(), "color": ColorWidget(),
} }
# "color": ColorWidget(),

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -33,6 +33,11 @@ class WEIClub(Club):
verbose_name=_("date end"), verbose_name=_("date end"),
) )
caution_amount = models.PositiveIntegerField(
verbose_name=_("caution amount"),
default=0,
)
class Meta: class Meta:
verbose_name = _("WEI") verbose_name = _("WEI")
verbose_name_plural = _("WEI") verbose_name_plural = _("WEI")
@ -72,6 +77,15 @@ class Bus(models.Model):
default=50, default=50,
) )
club = models.OneToOneField(
Club,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="bus",
verbose_name=_("club"),
)
description = models.TextField( description = models.TextField(
blank=True, blank=True,
default="", default="",
@ -188,6 +202,16 @@ class WEIRegistration(models.Model):
verbose_name=_("Caution check given") verbose_name=_("Caution check given")
) )
caution_type = models.CharField(
max_length=16,
choices=(
('check', _("Check")),
('note', _("Note transaction")),
),
default='check',
verbose_name=_("caution type"),
)
birth_date = models.DateField( birth_date = models.DateField(
verbose_name=_("birth date"), verbose_name=_("birth date"),
) )

View File

@ -98,7 +98,7 @@ class WEIRegistrationTable(tables.Table):
if not hasperm: if not hasperm:
return format_html("<span class='no-perm'></span>") return format_html("<span class='no-perm'></span>")
url = reverse_lazy('wei:validate_registration', args=(record.pk,)) url = reverse_lazy('wei:wei_update_registration', args=(record.pk,)) + '?validate=true'
text = _('Validate') text = _('Validate')
if record.fee > record.user.note.balance and not record.soge_credit: if record.fee > record.user.note.balance and not record.soge_credit:
btn_class = 'btn-secondary' btn_class = 'btn-secondary'

View File

@ -40,22 +40,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
<dt class="col-xl-6">{% trans 'membership fee'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'membership fee'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd> <dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd>
{% else %} {% else %}
{% with bde_kfet_fee=club.parent_club.membership_fee_paid|add:club.parent_club.parent_club.membership_fee_paid %}
<dt class="col-xl-6">{% trans 'WEI fee (paid students)'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_fee_paid|add:bde_kfet_fee|pretty_money }}
<i class="fa fa-question-circle"
title="{% trans "The BDE membership is included in the WEI registration." %}"></i></dd>
{% endwith %}
{% with bde_kfet_fee=club.parent_club.membership_fee_unpaid|add:club.parent_club.parent_club.membership_fee_unpaid %} <dt class="col-xl-6">{% trans 'WEI fee (paid students)'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}
<dt class="col-xl-6">{% trans 'WEI fee (unpaid students)'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'WEI fee (unpaid students)'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.membership_fee_unpaid|add:bde_kfet_fee|pretty_money }} <dd class="col-xl-6">{{ club.membership_fee_unpaid|pretty_money }}
<i class="fa fa-question-circle"
title="{% trans "The BDE membership is included in the WEI registration." %}"></i></dd>
{% endwith %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if club.caution_amount > 0 %}
<dt class="col-xl-6">{% trans 'Caution amount'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.caution_amount|pretty_money }}</dd>
{% endif %}
{% if "note.view_note"|has_perm:club.note %} {% if "note.view_note"|has_perm:club.note %}
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.note.balance | pretty_money }}</dd> <dd class="col-xl-6">{{ club.note.balance | pretty_money }}</dd>

View File

@ -16,6 +16,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
<div class="card-footer text-center"> <div class="card-footer text-center">
{% if object.club %}
<a class="btn btn-primary btn-sm my-1" href="{% url 'member:club_detail' pk=object.club.pk %}"
data-turbolinks="false">{% trans "View club" %}</a>
{% endif %}
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=object.pk %}" <a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=object.pk %}"
data-turbolinks="false">{% trans "Edit" %}</a> data-turbolinks="false">{% trans "Edit" %}</a>
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=object.pk %}" <a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=object.pk %}"

View File

@ -18,6 +18,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card-footer text-center"> <div class="card-footer text-center">
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=bus.pk %}" <a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=bus.pk %}"
data-turbolinks="false">{% trans "Edit" %}</a> data-turbolinks="false">{% trans "Edit" %}</a>
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:manage_bus' pk=bus.pk %}"
data-turbolinks="false">{% trans "View" %}</a>
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=bus.pk %}" <a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=bus.pk %}"
data-turbolinks="false">{% trans "Add team" %}</a> data-turbolinks="false">{% trans "Add team" %}</a>
</div> </div>

View File

@ -13,9 +13,17 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card-body"> <div class="card-body">
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.media }}
{{ form|crispy }} {{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button> <button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form> </form>
</div> </div>
</div> </div>
<script>
document.addEventListener("DOMContentLoaded", function () {
if (window.jscolor && jscolor.install) {
jscolor.install();
}
});
</script>
{% endblock %} {% endblock %}

View File

@ -95,9 +95,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
{% endif %} {% endif %}
{% if can_validate_1a %} {% if can_validate_1a %}
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a> <a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}

View File

@ -143,25 +143,35 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %} {% endblocktrans %}
</div> </div>
{% else %} {% else %}
{% if registration.user.note.balance < fee %} <div class="alert {% if registration.user.note.balance < fee %}alert-danger{% else %}alert-success{% endif %}">
<div class="alert alert-danger"> <h5>{% trans "Required payments:" %}</h5>
{% with pretty_fee=fee|pretty_money %} <ul>
{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %} <li>{% blocktrans trimmed with amount=fee|pretty_money %}
The note don't have enough money ({{ balance }}, {{ pretty_fee }} required). Membership fees: {{ amount }}
The registration may fail if you don't credit the note now. {% endblocktrans %}</li>
{% endblocktrans %} {% if registration.caution_type == 'note' %}
{% endwith %} <li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
</div> Deposit (by Note transaction): {{ amount }}
{% endblocktrans %}</li>
<li><strong>{% blocktrans trimmed with total=total_needed|pretty_money %}
Total needed: {{ total }}
{% endblocktrans %}</strong></li>
{% else %} {% else %}
<div class="alert alert-success"> <li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
{% blocktrans trimmed with pretty_fee=fee|pretty_money %} Deposit (by check): {{ amount }}
The note has enough money ({{ pretty_fee }} required), the registration is possible. {% endblocktrans %}</li>
{% endblocktrans %} <li><strong>{% blocktrans trimmed with total=fee|pretty_money %}
</div> Total needed: {{ total }}
{% endblocktrans %}</strong></li>
{% endif %} {% endif %}
</ul>
<p>{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
Current balance: {{ balance }}
{% endblocktrans %}</p>
</div>
{% endif %} {% endif %}
{% if not registration.caution_check and not registration.first_year %} {% if not registration.caution_check and not registration.first_year and registration.caution_type == 'check' %}
<div class="alert alert-danger"> <div class="alert alert-danger">
{% trans "The user didn't give her/his caution check." %} {% trans "The user didn't give her/his caution check." %}
</div> </div>

View File

@ -126,6 +126,7 @@ class TestWEIRegistration(TestCase):
year=self.year + 1, year=self.year + 1,
date_start=str(self.year + 1) + "-09-01", date_start=str(self.year + 1) + "-09-01",
date_end=str(self.year + 1) + "-09-03", date_end=str(self.year + 1) + "-09-03",
caution_amount=12000,
)) ))
qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1) qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1)
self.assertTrue(qs.exists()) self.assertTrue(qs.exists())
@ -160,6 +161,7 @@ class TestWEIRegistration(TestCase):
membership_end="2000-09-30", membership_end="2000-09-30",
date_start="2000-09-01", date_start="2000-09-01",
date_end="2000-09-03", date_end="2000-09-03",
caution_amount=12000,
)) ))
qs = WEIClub.objects.filter(name="Update WEI Test", id=self.wei.id) qs = WEIClub.objects.filter(name="Update WEI Test", id=self.wei.id)
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.wei.pk)), 302, 200) self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.wei.pk)), 302, 200)
@ -318,6 +320,7 @@ class TestWEIRegistration(TestCase):
bus=[], bus=[],
team=[], team=[],
roles=[], roles=[],
caution_type='check'
)) ))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(response.context["membership_form"].is_valid()) self.assertFalse(response.context["membership_form"].is_valid())
@ -334,7 +337,8 @@ class TestWEIRegistration(TestCase):
emergency_contact_phone='+33123456789', emergency_contact_phone='+33123456789',
bus=[self.bus.id], bus=[self.bus.id],
team=[self.team.id], team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()], roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")).all()],
caution_type='check'
)) ))
qs = WEIRegistration.objects.filter(user_id=user.id) qs = WEIRegistration.objects.filter(user_id=user.id)
self.assertTrue(qs.exists()) self.assertTrue(qs.exists())
@ -354,6 +358,7 @@ class TestWEIRegistration(TestCase):
bus=[self.bus.id], bus=[self.bus.id],
team=[self.team.id], team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()], roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()],
caution_type='check'
)) ))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue("This user is already registered to this WEI." in str(response.context["form"].errors)) self.assertTrue("This user is already registered to this WEI." in str(response.context["form"].errors))
@ -506,11 +511,12 @@ class TestWEIRegistration(TestCase):
team=[self.team.id], team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()], roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
information_json=self.registration.information_json, information_json=self.registration.information_json,
caution_type='check'
) )
) )
qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M") qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M")
self.assertTrue(qs.exists()) self.assertTrue(qs.exists())
self.assertRedirects(response, reverse("wei:validate_registration", kwargs=dict(pk=qs.get().pk)), 302, 200) self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=qs.get().wei.pk)), 302, 200)
# Check the page when the registration is already validated # Check the page when the registration is already validated
membership = WEIMembership( membership = WEIMembership(
@ -560,11 +566,12 @@ class TestWEIRegistration(TestCase):
team=[self.team.id], team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()], roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
information_json=self.registration.information_json, information_json=self.registration.information_json,
caution_type='check'
) )
) )
qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L") qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L")
self.assertTrue(qs.exists()) self.assertTrue(qs.exists())
self.assertRedirects(response, reverse("wei:validate_registration", kwargs=dict(pk=qs.get().pk)), 302, 200) self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=qs.get().wei.pk)), 302, 200)
# Test invalid form # Test invalid form
response = self.client.post( response = self.client.post(
@ -583,6 +590,7 @@ class TestWEIRegistration(TestCase):
team=[], team=[],
roles=[], roles=[],
information_json=self.registration.information_json, information_json=self.registration.information_json,
caution_type='check'
) )
) )
self.assertFalse(response.context["membership_form"].is_valid()) self.assertFalse(response.context["membership_form"].is_valid())
@ -624,7 +632,7 @@ class TestWEIRegistration(TestCase):
second_bus = Bus.objects.create(wei=self.wei, name="Second bus") second_bus = Bus.objects.create(wei=self.wei, name="Second bus")
second_team = BusTeam.objects.create(bus=second_bus, name="Second team", color=42) second_team = BusTeam.objects.create(bus=second_bus, name="Second team", color=42)
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict( response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
roles=[WEIRole.objects.get(name="GC WEI").id], roles=[WEIRole.objects.get(name="Adhérent⋅e WEI").id],
bus=self.bus.pk, bus=self.bus.pk,
team=second_team.pk, team=second_team.pk,
credit_type=4, # Bank transfer credit_type=4, # Bank transfer
@ -632,13 +640,14 @@ class TestWEIRegistration(TestCase):
last_name="admin", last_name="admin",
first_name="admin", first_name="admin",
bank="Société générale", bank="Société générale",
caution_check=True,
)) ))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(response.context["form"].is_valid()) self.assertFalse(response.context["form"].is_valid())
self.assertTrue("This team doesn&#x27;t belong to the given bus." in str(response.context["form"].errors)) self.assertTrue("This team doesn&#x27;t belong to the given bus." in str(response.context["form"].errors))
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict( response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
roles=[WEIRole.objects.get(name="GC WEI").id], roles=[WEIRole.objects.get(name="Adhérent⋅e WEI").id],
bus=self.bus.pk, bus=self.bus.pk,
team=self.team.pk, team=self.team.pk,
credit_type=4, # Bank transfer credit_type=4, # Bank transfer
@ -646,8 +655,10 @@ class TestWEIRegistration(TestCase):
last_name="admin", last_name="admin",
first_name="admin", first_name="admin",
bank="Société générale", bank="Société générale",
caution_check=True,
)) ))
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200) self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
# Check if the membership is successfully created # Check if the membership is successfully created
membership = WEIMembership.objects.filter(user_id=self.user.id, club=self.wei) membership = WEIMembership.objects.filter(user_id=self.user.id, club=self.wei)
self.assertTrue(membership.exists()) self.assertTrue(membership.exists())

View File

@ -4,16 +4,18 @@
import os import os
import shutil import shutil
import subprocess import subprocess
from datetime import date, timedelta from datetime import date
from tempfile import mkdtemp from tempfile import mkdtemp
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q, Count from django.db.models import Q, Count
from django.db.models.functions.text import Lower from django.db.models.functions.text import Lower
from django import forms
from django.http import HttpResponse, Http404 from django.http import HttpResponse, Http404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
@ -33,7 +35,7 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms.registration import WEIChooseBusForm from .forms.registration import WEIChooseBusForm
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \ from .forms import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, BusForm, BusTeamForm, WEIMembership1AForm, \
WEIMembershipForm, CurrentSurvey WEIMembershipForm, CurrentSurvey
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \ from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
WEIRegistration1ATable, WEIMembershipTable WEIRegistration1ATable, WEIMembershipTable
@ -441,6 +443,10 @@ class BusTeamCreateView(ProtectQuerysetMixin, ProtectedCreateView):
self.object.refresh_from_db() self.object.refresh_from_db()
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk}) return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
def get_template_names(self):
names = super().get_template_names()
return names
class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
@ -473,6 +479,10 @@ class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
self.object.refresh_from_db() self.object.refresh_from_db()
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk}) return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
def get_template_names(self):
names = super().get_template_names()
return names
class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
@ -500,7 +510,7 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
Register a new user to the WEI Register a new user to the WEI
""" """
model = WEIRegistration model = WEIRegistration
form_class = WEIRegistrationForm form_class = WEIRegistration1AForm
extra_context = {"title": _("Register first year student to the WEI")} extra_context = {"title": _("Register first year student to the WEI")}
def get_sample_object(self): def get_sample_object(self):
@ -546,9 +556,17 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
form.fields["user"].initial = self.request.user form.fields["user"].initial = self.request.user
# Cacher les champs pendant l'inscription initiale
if "first_year" in form.fields:
del form.fields["first_year"] del form.fields["first_year"]
if "caution_check" in form.fields:
del form.fields["caution_check"] del form.fields["caution_check"]
if "information_json" in form.fields:
del form.fields["information_json"] del form.fields["information_json"]
if "caution_type" in form.fields:
del form.fields["caution_type"]
return form return form
@transaction.atomic @transaction.atomic
@ -586,7 +604,7 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
Register an old user to the WEI Register an old user to the WEI
""" """
model = WEIRegistration model = WEIRegistration
form_class = WEIRegistrationForm form_class = WEIRegistration2AForm
extra_context = {"title": _("Register old student to the WEI")} extra_context = {"title": _("Register old student to the WEI")}
def get_sample_object(self): def get_sample_object(self):
@ -644,10 +662,20 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
form.fields["soge_credit"].disabled = True form.fields["soge_credit"].disabled = True
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.") form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
del form.fields["caution_check"] # Cacher les champs pendant l'inscription initiale
if "first_year" in form.fields:
del form.fields["first_year"] del form.fields["first_year"]
if "caution_check" in form.fields:
del form.fields["caution_check"]
if "information_json" in form.fields:
del form.fields["information_json"] del form.fields["information_json"]
# S'assurer que le champ caution_type est obligatoire
if "caution_type" in form.fields:
form.fields["caution_type"].required = True
form.fields["caution_type"].help_text = _("Choose how you want to pay the deposit")
form.fields["caution_type"].widget = forms.RadioSelect(choices=form.fields["caution_type"].choices)
return form return form
@transaction.atomic @transaction.atomic
@ -673,6 +701,9 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]] information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]]
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]] information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
form.instance.information = information form.instance.information = information
# Sauvegarder le type de caution
form.instance.caution_type = form.cleaned_data["caution_type"]
form.instance.save() form.instance.save()
if 'treasury' in settings.INSTALLED_APPS: if 'treasury' in settings.INSTALLED_APPS:
@ -702,11 +733,15 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
# We can't update a registration once the WEI is started and before the membership start date # We can't update a registration once the WEI is started and before the membership start date
if today >= wei.date_start or today < wei.membership_start: if today >= wei.date_start or today < wei.membership_start:
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,))) return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
# Store the validate parameter in the view's state
self.should_validate = request.GET.get('validate', False)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["club"] = self.object.wei context["club"] = self.object.wei
# Pass the validate parameter to the template
context["should_validate"] = self.should_validate
if self.object.is_validated: if self.object.is_validated:
membership_form = self.get_membership_form(instance=self.object.membership, membership_form = self.get_membership_form(instance=self.object.membership,
@ -740,6 +775,16 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
# The auto-json-format may cause issues with the default field remove # The auto-json-format may cause issues with the default field remove
if not PermissionBackend.check_perm(self.request, 'wei.change_weiregistration_information_json', self.object): if not PermissionBackend.check_perm(self.request, 'wei.change_weiregistration_information_json', self.object):
del form.fields["information_json"] del form.fields["information_json"]
# Masquer le champ caution_check pour tout le monde dans le formulaire de modification
if "caution_check" in form.fields:
del form.fields["caution_check"]
# S'assurer que le champ caution_type est obligatoire pour les 2A+
if not self.object.first_year and "caution_type" in form.fields:
form.fields["caution_type"].required = True
form.fields["caution_type"].help_text = _("Choose how you want to pay the deposit")
form.fields["caution_type"].widget = forms.RadioSelect(choices=form.fields["caution_type"].choices)
return form return form
def get_membership_form(self, data=None, instance=None): def get_membership_form(self, data=None, instance=None):
@ -759,10 +804,30 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
def form_valid(self, form): def form_valid(self, form):
# If the membership is already validated, then we update the bus and the team (and the roles) # If the membership is already validated, then we update the bus and the team (and the roles)
if form.instance.is_validated: if form.instance.is_validated:
membership_form = self.get_membership_form(self.request.POST, form.instance.membership) try:
membership = form.instance.membership
if membership is None:
raise ValueError(_("No membership found for this registration"))
membership_form = self.get_membership_form(self.request.POST, instance=membership)
if not membership_form.is_valid(): if not membership_form.is_valid():
return self.form_invalid(form) return self.form_invalid(form)
# Vérifier que l'utilisateur a la permission de modifier le membership
# On vérifie d'abord si l'utilisateur a la permission générale de modification
if not self.request.user.has_perm("wei.change_weimembership"):
raise PermissionDenied(_("You don't have the permission to update memberships"))
# On vérifie ensuite les permissions spécifiques pour chaque champ modifié
for field_name in membership_form.changed_data:
perm = f"wei.change_weimembership_{field_name}"
if not self.request.user.has_perm(perm):
raise PermissionDenied(_("You don't have the permission to update the field %(field)s") % {'field': field_name})
membership_form.save() membership_form.save()
except (WEIMembership.DoesNotExist, ValueError, PermissionDenied) as e:
form.add_error(None, str(e))
return self.form_invalid(form)
# If it is not validated and if this is an old member, then we update the choices # If it is not validated and if this is an old member, then we update the choices
elif not form.instance.first_year and PermissionBackend.check_perm( elif not form.instance.first_year and PermissionBackend.check_perm(
self.request, "wei.change_weiregistration_information_json", self.object): self.request, "wei.change_weiregistration_information_json", self.object):
@ -777,6 +842,10 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]] information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]]
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]] information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
form.instance.information = information form.instance.information = information
# Sauvegarder le type de caution pour les 2A+
if "caution_type" in form.cleaned_data:
form.instance.caution_type = form.cleaned_data["caution_type"]
form.instance.save() form.instance.save()
return super().form_valid(form) return super().form_valid(form)
@ -787,14 +856,8 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
survey = CurrentSurvey(self.object) survey = CurrentSurvey(self.object)
if not survey.is_complete(): if not survey.is_complete():
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk}) return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
if PermissionBackend.check_perm(self.request, "wei.add_weimembership", WEIMembership( # On redirige vers la validation uniquement si c'est explicitement demandé (et stocké dans la vue)
club=self.object.wei, if self.should_validate and self.request.user.has_perm("wei.add_weimembership"):
user=self.object.user,
date_start=date.today(),
date_end=date.today(),
fee=0,
registration=self.object,
)):
return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk}) return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk})
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.pk}) return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.pk})
@ -836,18 +899,23 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
extra_context = {"title": _("Validate WEI registration")} extra_context = {"title": _("Validate WEI registration")}
def get_sample_object(self): def get_sample_object(self):
"""
Return a sample object for permission checking
"""
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"]) registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
return WEIMembership( return WEIMembership(
club=registration.wei,
user=registration.user, user=registration.user,
date_start=date.today(), club=registration.wei,
date_end=date.today() + timedelta(days=1), date_start=registration.wei.date_start,
fee=0, fee=registration.wei.membership_fee_paid if registration.user.profile.paid else registration.wei.membership_fee_unpaid,
# Add any fields needed for proper permission checking
registration=registration, registration=registration,
) )
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
wei = WEIRegistration.objects.get(pk=self.kwargs["pk"]).wei registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
wei = registration.wei
today = date.today() today = date.today()
# We can't validate anyone once the WEI is started and before the membership start date # We can't validate anyone once the WEI is started and before the membership start date
if today >= wei.date_start or today < wei.membership_start: if today >= wei.date_start or today < wei.membership_start:
@ -878,7 +946,14 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
date_start__gte=bde.membership_start, date_start__gte=bde.membership_start,
).exists() ).exists()
context["fee"] = registration.fee fee = registration.fee
context["fee"] = fee
# Calculer le montant total nécessaire (frais + caution si transaction)
total_needed = fee
if registration.caution_type == 'note':
total_needed += registration.wei.caution_amount
context["total_needed"] = total_needed
form = context["form"] form = context["form"]
if registration.soge_credit: if registration.soge_credit:
@ -900,8 +975,24 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form.fields["last_name"].initial = registration.user.last_name form.fields["last_name"].initial = registration.user.last_name
form.fields["first_name"].initial = registration.user.first_name form.fields["first_name"].initial = registration.user.first_name
if "caution_check" in form.fields: # Ajouter le champ caution_check uniquement pour les non-première année et le rendre obligatoire
form.fields["caution_check"].initial = registration.caution_check if not registration.first_year:
if registration.caution_type == 'check':
form.fields["caution_check"] = forms.BooleanField(
required=True,
initial=registration.caution_check,
label=_("Caution check given"),
help_text=_("Please make sure the check is given before validating the registration")
)
else:
form.fields["caution_check"] = forms.BooleanField(
required=True,
initial=False,
label=_("Create deposit transaction"),
help_text=_("A transaction of %(amount).2f€ will be created from the user's Note account") % {
'amount': registration.wei.caution_amount / 100
}
)
if registration.soge_credit: if registration.soge_credit:
form.fields["credit_type"].disabled = True form.fields["credit_type"].disabled = True
@ -985,10 +1076,20 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
if credit_type is None or registration.soge_credit: if credit_type is None or registration.soge_credit:
credit_amount = 0 credit_amount = 0
if not registration.soge_credit and user.note.balance + credit_amount < fee: # Calculer le montant total nécessaire (frais + caution si transaction)
# Users must have money before registering to the WEI. total_needed = fee
if registration.caution_type == 'note':
total_needed += club.caution_amount
# Vérifier que l'utilisateur a assez d'argent pour tout payer
if not registration.soge_credit and user.note.balance + credit_amount < total_needed:
form.add_error('credit_type', form.add_error('credit_type',
_("This user don't have enough money to join this club, and can't have a negative balance.")) _("This user doesn't have enough money to join this club and pay the deposit. "
"Current balance: %(balance)d€, credit: %(credit)d€, needed: %(needed)d") % {
'balance': user.note.balance,
'credit': credit_amount,
'needed': total_needed}
)
return super().form_invalid(form) return super().form_invalid(form)
if credit_amount: if credit_amount:
@ -1028,6 +1129,18 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
membership.refresh_from_db() membership.refresh_from_db()
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI")) membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
# Créer la transaction de caution si nécessaire
if registration.caution_type == 'note':
from note.models import Transaction
Transaction.objects.create(
source=user.note,
destination=club.note,
quantity=1,
amount=club.caution_amount,
reason=_("Caution %(name)s") % {'name': club.name},
valid=True,
)
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
@ -1289,8 +1402,22 @@ class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
if not wei.exists(): if not wei.exists():
raise Http404 raise Http404
wei = wei.get() wei = wei.get()
qs = WEIRegistration.objects.filter(wei=wei, membership__isnull=False, membership__bus__isnull=True)
qs = qs.filter(information_json__contains='selected_bus_pk') # not perfect, but works... # On cherche d'abord les 1A qui ont une inscription validée (membership) mais pas de bus
if qs.exists(): qs = WEIRegistration.objects.filter(
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk, )) wei=wei,
return reverse_lazy('wei:wei_1A_list', args=(wei.pk, )) first_year=True,
membership__isnull=False,
membership__bus__isnull=True
)
# Parmi eux, on prend ceux qui ont répondu au questionnaire (ont un bus préféré)
qs = qs.filter(information_json__contains='selected_bus_pk')
if not qs.exists():
# Si on ne trouve personne, on affiche un message et on retourne à la liste
messages.info(self.request, _("No first year student without a bus found. Either all of them have a bus, or none has filled the survey yet."))
return reverse_lazy('wei:wei_1A_list', args=(wei.pk,))
# On redirige vers la page d'attribution pour le premier étudiant trouvé
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk,))

View File

@ -19,8 +19,9 @@ Le modèle regroupe :
* Propriétaire (doit-être un Club) * Propriétaire (doit-être un Club)
* Allergènes (ManyToManyField) * Allergènes (ManyToManyField)
* date d'expiration * date d'expiration
* a été mangé (booléen) * fin de vie
* est prêt (booléen) * est prêt (booléen)
* consigne (pour les GCKs)
BasicFood BasicFood
~~~~~~~~~ ~~~~~~~~~
@ -40,7 +41,7 @@ Les TransformedFood correspondent aux produits préparés à la Kfet. Ils peuven
Le modèle regroupe : Le modèle regroupe :
* Durée de consommation (par défaut 3 jours) * Durée de conservation (par défaut 3 jours)
* Ingrédients (ManyToManyField vers Food) * Ingrédients (ManyToManyField vers Food)
* Date de création * Date de création
* Champs de Food * Champs de Food

View File

@ -12,6 +12,7 @@ Applications de la Note Kfet 2020
../api/index ../api/index
registration registration
logs logs
food
treasury treasury
wei wei
wrapped wrapped
@ -66,6 +67,8 @@ Applications facultatives
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client. Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
* `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_ * `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc... Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc...
* `Food <food>`_ :
Gestion de la nourriture dans Kfet pour les clubs.
* `Treasury <treasury>`_ : * `Treasury <treasury>`_ :
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques... Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques...
* `WEI <wei>`_ : * `WEI <wei>`_ :

View File

@ -183,6 +183,7 @@ Contributeur⋅rices
* korenst1 * korenst1
* nicomarg * nicomarg
* PAC * PAC
* Quark
* ÿnérant * ÿnérant

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -27,5 +27,5 @@ MAILTO=notekfet2020@lists.crans.org
# Vider les tokens Oauth2 # Vider les tokens Oauth2
00 6 * * * root cd /var/www/note_kfet && env/bin/python manage.py cleartokens -v 0 00 6 * * * root cd /var/www/note_kfet && env/bin/python manage.py cleartokens -v 0
# Envoyer la liste des abonnés à la NL BDA # Envoyer la liste des abonnés à la NL BDA
00 10 * * 0 root cd /var/www/note_kfet && env/bin/python manage.py extract_ml_registrations -t art 00 10 * * 0 root cd /var/www/note_kfet && env/bin/python manage.py extract_ml_registrations -t art -e "bda.ensparissaclay@gmail.com"

View File

@ -63,8 +63,16 @@ class ColorWidget(Widget):
def format_value(self, value): def format_value(self, value):
if value is None: if value is None:
value = 0xFFFFFF value = 0xFFFFFF
if isinstance(value, str):
return value # Assume it's already a hex string like "#FFAA33"
try:
return "#{:06X}".format(value) return "#{:06X}".format(value)
except Exception:
return "#FFFFFF"
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
val = super().value_from_datadict(data, files, name) val = super().value_from_datadict(data, files, name)
if val:
return int(val[1:], 16) return int(val[1:], 16)
return None

View File

@ -0,0 +1,5 @@
<input type="text"
name="{{ widget.name }}"
value="{{ widget.value }}"
class="jscolor"
{% include "django/forms/widgets/attrs.html" %}>