mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-10-31 15:50:03 +01:00 
			
		
		
		
	Compare commits
	
		
			20 Commits
		
	
	
		
			family
			...
			note_sheet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 6bf21b103f | ||
|  | d4cb464169 | ||
|  | 27a1f36183 | ||
|  | 83c8b9a3d0 | ||
|  | cb3b34f874 | ||
|  | 0962a3735e | ||
|  | 9907cfbd86 | ||
|  | ad90887691 | ||
|  | 47d2476b51 | ||
|  | 5d8720cf46 | ||
|  | 8700144dea | ||
|  | d17ab26f2f | ||
|  | 297f289d7e | ||
|  | 034ad9a4ce | ||
|  | 897d37f74d | ||
|  | 42fb0aa2d6 | ||
|  | 4bc43ec3cb | ||
|  | 00737da69f | ||
|  | 0934b8fa34 | ||
|  | 7633c9ab4b | 
| @@ -48,5 +48,15 @@ | |||||||
|             "can_invite": true, |             "can_invite": true, | ||||||
|             "guest_entry_fee": 0 |             "guest_entry_fee": 0 | ||||||
|         } |         } | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         "model": "activity.activitytype", | ||||||
|  |         "pk": 8, | ||||||
|  |         "fields": { | ||||||
|  |             "name": "Perm bouffe", | ||||||
|  |             "manage_entries": false, | ||||||
|  |             "can_invite": false, | ||||||
|  |             "guest_entry_fee": 0 | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| ] | ] | ||||||
| @@ -37,6 +37,11 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|     <div id="guests_table"> |     <div id="guests_table"> | ||||||
|         {% render_table guests %} |         {% render_table guests %} | ||||||
|     </div> |     </div> | ||||||
|  |     <div class="card-footer text-center"> | ||||||
|  |         <button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'"> | ||||||
|  |             {% trans "Export to CSV" %} | ||||||
|  |         </button> | ||||||
|  |     </div> | ||||||
| </div> | </div> | ||||||
| {% endif %} | {% endif %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| {% extends "base.html" %} | {% extends "base_search.html" %} | ||||||
| {% comment %} | {% comment %} | ||||||
| SPDX-License-Identifier: GPL-3.0-or-later | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| {% endcomment %} | {% endcomment %} | ||||||
| @@ -44,6 +44,8 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|     <h3 class="card-header text-center"> |     <h3 class="card-header text-center"> | ||||||
|         {% trans "All activities" %} |         {% trans "All activities" %} | ||||||
|     </h3> |     </h3> | ||||||
|     {% render_table table %} |     {% render_table all %} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | {{ block.super }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| {% comment %} | {% comment %} | ||||||
| SPDX-License-Identifier: GPL-3.0-or-later | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| {% endcomment %} | {% endcomment %} | ||||||
| {% load i18n perms pretty_money %} | {% load i18n perms pretty_money dict_get %} | ||||||
| {% url 'activity:activity_detail' activity.pk as activity_detail_url %} | {% url 'activity:activity_detail' activity.pk as activity_detail_url %} | ||||||
|  |  | ||||||
| <div id="activity_info" class="card bg-light shadow mb-3"> | <div id="activity_info" class="card bg-light shadow mb-3"> | ||||||
| @@ -53,6 +53,12 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|             <dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt> |             <dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt> | ||||||
|             <dd class="col-xl-6">{{ activity.open|yesno }}</dd> |             <dd class="col-xl-6">{{ activity.open|yesno }}</dd> | ||||||
|         </dl> |         </dl> | ||||||
|  |         {% if show_entries|dict_get:activity %} | ||||||
|  |             <h2 class="text-center"> | ||||||
|  |                 {{ entries_count|dict_get:activity }} | ||||||
|  |                 {% if entries_count|dict_get:activity >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %} | ||||||
|  |             </h2> | ||||||
|  |         {% endif %} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <div class="card-footer text-center"> |     <div class="card-footer text-center"> | ||||||
| @@ -60,6 +66,10 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|             <a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a> |             <a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|  |  | ||||||
|  |         {% if activity.activity_type.name == "Perm bouffe" %} | ||||||
|  |             <a class="btn btn-warning btn-sm my-1" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> {% trans "Dish page" %}</a> | ||||||
|  |         {% endif %} | ||||||
|  |  | ||||||
|         {% if request.path_info == activity_detail_url %} |         {% if request.path_info == activity_detail_url %} | ||||||
|             {% if activity.valid and ".change__open"|has_perm:activity %} |             {% if activity.valid and ".change__open"|has_perm:activity %} | ||||||
|                 <a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a> |                 <a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a> | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								apps/activity/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/activity/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										12
									
								
								apps/activity/templatetags/dict_get.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								apps/activity/templatetags/dict_get.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
|  | # SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  |  | ||||||
|  | from django import template | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def dict_get(d, key): | ||||||
|  |     return d.get(key) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | register = template.Library() | ||||||
|  | register.filter('dict_get', dict_get) | ||||||
| @@ -67,32 +67,65 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin | |||||||
|     tables = [ |     tables = [ | ||||||
|         lambda data: ActivityTable(data, prefix="all-"), |         lambda data: ActivityTable(data, prefix="all-"), | ||||||
|         lambda data: ActivityTable(data, prefix="upcoming-"), |         lambda data: ActivityTable(data, prefix="upcoming-"), | ||||||
|  |         lambda data: ActivityTable(data, prefix="search-"), | ||||||
|     ] |     ] | ||||||
|     extra_context = {"title": _("Activities")} |     extra_context = {"title": _("Activities")} | ||||||
|  |  | ||||||
|     def get_queryset(self, **kwargs): |     def get_queryset(self, **kwargs): | ||||||
|         return super().get_queryset(**kwargs).distinct() |         """ | ||||||
|  |         Filter the user list with the given pattern. | ||||||
|  |         """ | ||||||
|  |         return super().get_queryset().distinct() | ||||||
|  |  | ||||||
|     def get_tables_data(self): |     def get_tables_data(self): | ||||||
|         # first table = all activities, second table = upcoming |         # first table = all activities, second table = upcoming, third table = search | ||||||
|  |  | ||||||
|  |         # table search | ||||||
|  |         qs = self.get_queryset().order_by('-date_start') | ||||||
|  |         if "search" in self.request.GET and self.request.GET['search']: | ||||||
|  |             pattern = self.request.GET['search'] | ||||||
|  |  | ||||||
|  |             # check regex | ||||||
|  |             valid_regex = is_regex(pattern) | ||||||
|  |             suffix = '__iregex' if valid_regex else '__istartswith' | ||||||
|  |             prefix = '^' if valid_regex else '' | ||||||
|  |             qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) | ||||||
|  |                            | Q(**{f'organizer__name{suffix}': prefix + pattern}) | ||||||
|  |                            | Q(**{f'organizer__note__alias__name{suffix}': prefix + pattern})) | ||||||
|  |         else: | ||||||
|  |             qs = qs.none() | ||||||
|  |         search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Activity, 'view')) | ||||||
|  |  | ||||||
|         return [ |         return [ | ||||||
|             self.get_queryset().order_by("-date_start"), |             self.get_queryset().order_by("-date_start"), | ||||||
|             Activity.objects.filter(date_end__gt=timezone.now()) |             Activity.objects.filter(date_end__gt=timezone.now()) | ||||||
|                             .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")) |                             .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")) | ||||||
|                             .distinct() |                             .distinct() | ||||||
|                             .order_by("date_start") |                             .order_by("date_start"), | ||||||
|  |             search_table, | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs): |     def get_context_data(self, **kwargs): | ||||||
|         context = super().get_context_data(**kwargs) |         context = super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|         tables = context["tables"] |         tables = context["tables"] | ||||||
|         for name, table in zip(["table", "upcoming"], tables): |         for name, table in zip(["all", "upcoming", "table"], tables): | ||||||
|             context[name] = table |             context[name] = table | ||||||
|  |  | ||||||
|         started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all() |         started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all() | ||||||
|         context["started_activities"] = started_activities |         context["started_activities"] = started_activities | ||||||
|  |  | ||||||
|  |         entries_count = {} | ||||||
|  |         show_entries = {} | ||||||
|  |         for activity in started_activities: | ||||||
|  |             if activity.activity_type.manage_entries: | ||||||
|  |                 entries = Entry.objects.filter(activity=activity) | ||||||
|  |                 entries_count[activity] = entries.count() | ||||||
|  |  | ||||||
|  |                 show_entries[activity] = True | ||||||
|  |         context["entries_count"] = entries_count | ||||||
|  |         context["show_entries"] = show_entries | ||||||
|  |  | ||||||
|         return context |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -103,12 +136,19 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix | |||||||
|     model = Activity |     model = Activity | ||||||
|     context_object_name = "activity" |     context_object_name = "activity" | ||||||
|     extra_context = {"title": _("Activity detail")} |     extra_context = {"title": _("Activity detail")} | ||||||
|  |     export_formats = ["csv"] | ||||||
|  |  | ||||||
|     tables = [ |     tables = [ | ||||||
|         lambda data: GuestTable(data, prefix="guests-"), |         GuestTable, | ||||||
|         lambda data: OpenerTable(data, prefix="opener-"), |         OpenerTable, | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |     def get_tables(self): | ||||||
|  |         tables = super().get_tables() | ||||||
|  |         tables[0].prefix = "guests" | ||||||
|  |         tables[1].prefix = "opener" | ||||||
|  |         return tables | ||||||
|  |  | ||||||
|     def get_tables_data(self): |     def get_tables_data(self): | ||||||
|         return [ |         return [ | ||||||
|             Guest.objects.filter(activity=self.object) |             Guest.objects.filter(activity=self.object) | ||||||
| @@ -117,6 +157,51 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix | |||||||
|                               .filter(PermissionBackend.filter_queryset(self.request, Opener, "view")), |                               .filter(PermissionBackend.filter_queryset(self.request, Opener, "view")), | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |     def render_to_response(self, context, **response_kwargs): | ||||||
|  |         """ | ||||||
|  |         Gère l'export CSV manuel pour MultiTableMixin. | ||||||
|  |         """ | ||||||
|  |         if "_export" in self.request.GET: | ||||||
|  |             import tablib | ||||||
|  |             table_name = self.request.GET.get("table") | ||||||
|  |             if table_name: | ||||||
|  |                 tables = self.get_tables() | ||||||
|  |                 data_list = self.get_tables_data() | ||||||
|  |  | ||||||
|  |                 for t, d in zip(tables, data_list): | ||||||
|  |                     if t.prefix == table_name: | ||||||
|  |                         # Préparer le CSV | ||||||
|  |                         dataset = tablib.Dataset() | ||||||
|  |                         columns = list(t.base_columns)  # noms des colonnes | ||||||
|  |                         dataset.headers = columns | ||||||
|  |  | ||||||
|  |                         for row in d: | ||||||
|  |                             values = [] | ||||||
|  |                             for col in columns: | ||||||
|  |                                 try: | ||||||
|  |                                     val = getattr(row, col, "") | ||||||
|  |                                     # Gestion spéciale pour la colonne 'entry' | ||||||
|  |                                     if col == "entry": | ||||||
|  |                                         if getattr(row, "has_entry", False): | ||||||
|  |                                             val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S") | ||||||
|  |                                         else: | ||||||
|  |                                             val = "" | ||||||
|  |                                     values.append(str(val) if val is not None else "") | ||||||
|  |                                 except Exception:  # RelatedObjectDoesNotExist ou autre | ||||||
|  |                                     values.append("") | ||||||
|  |                             dataset.append(values) | ||||||
|  |  | ||||||
|  |                         csv_bytes = dataset.export("csv") | ||||||
|  |                         if isinstance(csv_bytes, str): | ||||||
|  |                             csv_bytes = csv_bytes.encode("utf-8") | ||||||
|  |  | ||||||
|  |                         response = HttpResponse(csv_bytes, content_type="text/csv") | ||||||
|  |                         response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"' | ||||||
|  |                         return response | ||||||
|  |  | ||||||
|  |         # Sinon rendu normal | ||||||
|  |         return super().render_to_response(context, **response_kwargs) | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs): |     def get_context_data(self, **kwargs): | ||||||
|         context = super().get_context_data() |         context = super().get_context_data() | ||||||
|  |  | ||||||
| @@ -137,6 +222,14 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix | |||||||
|                 "placeholder": "" |                 "placeholder": "" | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |         if self.object.activity_type.manage_entries: | ||||||
|  |             entries = Entry.objects.filter(activity=self.object) | ||||||
|  |             context["entries_count"] = {self.object: entries.count()} | ||||||
|  |  | ||||||
|  |             context["show_entries"] = {self.object: timezone.now() > timezone.localtime(self.object.date_start)} | ||||||
|  |         else: | ||||||
|  |             context["entries_count"] = {self.object: 0} | ||||||
|  |             context["show_entries"] = {self.object: False} | ||||||
|  |  | ||||||
|         return context |         return context | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
|  |  | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
|  |  | ||||||
| from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode | from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction | ||||||
|  |  | ||||||
|  |  | ||||||
| class AllergenSerializer(serializers.ModelSerializer): | class AllergenSerializer(serializers.ModelSerializer): | ||||||
| @@ -54,3 +54,43 @@ class QRCodeSerializer(serializers.ModelSerializer): | |||||||
|     class Meta: |     class Meta: | ||||||
|         model = QRCode |         model = QRCode | ||||||
|         fields = '__all__' |         fields = '__all__' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishSerializer(serializers.ModelSerializer): | ||||||
|  |     """ | ||||||
|  |     REST API Serializer for Dish. | ||||||
|  |     The djangorestframework plugin will analyse the model `Dish` and parse all fields in the API. | ||||||
|  |     """ | ||||||
|  |     class Meta: | ||||||
|  |         model = Dish | ||||||
|  |         fields = '__all__' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SupplementSerializer(serializers.ModelSerializer): | ||||||
|  |     """ | ||||||
|  |     REST API Serializer for Supplement. | ||||||
|  |     The djangorestframework plugin will analyse the model `Supplement` and parse all fields in the API. | ||||||
|  |     """ | ||||||
|  |     class Meta: | ||||||
|  |         model = Supplement | ||||||
|  |         fields = '__all__' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OrderSerializer(serializers.ModelSerializer): | ||||||
|  |     """ | ||||||
|  |     REST API Serializer for Order. | ||||||
|  |     The djangorestframework plugin will analyse the model `Order` and parse all fields in the API. | ||||||
|  |     """ | ||||||
|  |     class Meta: | ||||||
|  |         model = Order | ||||||
|  |         fields = '__all__' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FoodTransactionSerializer(serializers.ModelSerializer): | ||||||
|  |     """ | ||||||
|  |     REST API Serializer for FoodTransaction. | ||||||
|  |     The djangorestframework plugin will analyse the model `FoodTransaction` and parse all fields in the API. | ||||||
|  |     """ | ||||||
|  |     class Meta: | ||||||
|  |         model = FoodTransaction | ||||||
|  |         fields = '__all__' | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
| # SPDX-License-Identifier: GPL-3.0-or-later | # SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  |  | ||||||
| from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet | from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \ | ||||||
|  |     DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet | ||||||
|  |  | ||||||
|  |  | ||||||
| def register_food_urls(router, path): | def register_food_urls(router, path): | ||||||
| @@ -13,3 +14,7 @@ def register_food_urls(router, path): | |||||||
|     router.register(path + '/basicfood', BasicFoodViewSet) |     router.register(path + '/basicfood', BasicFoodViewSet) | ||||||
|     router.register(path + '/transformedfood', TransformedFoodViewSet) |     router.register(path + '/transformedfood', TransformedFoodViewSet) | ||||||
|     router.register(path + '/qrcode', QRCodeViewSet) |     router.register(path + '/qrcode', QRCodeViewSet) | ||||||
|  |     router.register(path + '/dish', DishViewSet) | ||||||
|  |     router.register(path + '/supplement', SupplementViewSet) | ||||||
|  |     router.register(path + '/order', OrderViewSet) | ||||||
|  |     router.register(path + '/foodtransaction', FoodTransactionViewSet) | ||||||
|   | |||||||
| @@ -3,10 +3,12 @@ | |||||||
|  |  | ||||||
| from api.viewsets import ReadProtectedModelViewSet | from api.viewsets import ReadProtectedModelViewSet | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
|  | from django.utils import timezone | ||||||
| from rest_framework.filters import SearchFilter | from rest_framework.filters import SearchFilter | ||||||
|  |  | ||||||
| from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer | from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \ | ||||||
| from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode |     DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer | ||||||
|  | from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction | ||||||
|  |  | ||||||
|  |  | ||||||
| class AllergenViewSet(ReadProtectedModelViewSet): | class AllergenViewSet(ReadProtectedModelViewSet): | ||||||
| @@ -72,3 +74,61 @@ 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 DishViewSet(ReadProtectedModelViewSet): | ||||||
|  |     """ | ||||||
|  |     REST API View set. | ||||||
|  |     The djangorestframework plugin will get all `Dish` objects, serialize it to JSON with the given serializer, | ||||||
|  |     then render it on /api/food/dish/ | ||||||
|  |     """ | ||||||
|  |     queryset = Dish.objects.order_by('id') | ||||||
|  |     serializer_class = DishSerializer | ||||||
|  |     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||||
|  |     filterset_fields = ['main__name', 'activity', ] | ||||||
|  |     search_fields = ['$main__name', '$activity', ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SupplementViewSet(ReadProtectedModelViewSet): | ||||||
|  |     """ | ||||||
|  |     REST API View set. | ||||||
|  |     The djangorestframework plugin will get all `Supplement` objects, serialize it to JSON with the given serializer, | ||||||
|  |     then render it on /api/food/supplement/ | ||||||
|  |     """ | ||||||
|  |     queryset = Supplement.objects.order_by('id') | ||||||
|  |     serializer_class = SupplementSerializer | ||||||
|  |     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||||
|  |     filterset_fields = ['food__name', 'dish__activity', ] | ||||||
|  |     search_fields = ['$food__name', '$dish__activity', ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OrderViewSet(ReadProtectedModelViewSet): | ||||||
|  |     """ | ||||||
|  |     REST API View set. | ||||||
|  |     The djangorestframework plugin will get all `Order` objects, serialize it to JSON with the given serializer, | ||||||
|  |     then render it on /api/food/order/ | ||||||
|  |     """ | ||||||
|  |     queryset = Order.objects.order_by('id') | ||||||
|  |     serializer_class = OrderSerializer | ||||||
|  |     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||||
|  |     filterset_fields = ['user', 'activity', 'dish', 'supplements', 'number', ] | ||||||
|  |     search_fields = ['$user', '$activity', '$dish', '$supplements', '$number', ] | ||||||
|  |  | ||||||
|  |     def perform_update(self, serializer): | ||||||
|  |         instance = serializer.save() | ||||||
|  |         if instance.served and not instance.served_at: | ||||||
|  |             instance.served_at = timezone.now() | ||||||
|  |             instance.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FoodTransactionViewSet(ReadProtectedModelViewSet): | ||||||
|  |     """ | ||||||
|  |     REST API View set. | ||||||
|  |     The djangorestframework plugin will get all `FoodTransaction` objects, serialize it to JSON with the given serializer, | ||||||
|  |     then render it on /api/food/foodtransaction/ | ||||||
|  |     """ | ||||||
|  |     queryset = FoodTransaction.objects.order_by('id') | ||||||
|  |     serializer_class = FoodTransactionSerializer | ||||||
|  |     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||||
|  |     filterset_fields = ['order', ] | ||||||
|  |     search_fields = ['$order', ] | ||||||
|   | |||||||
| @@ -4,15 +4,16 @@ | |||||||
| from random import shuffle | from random import shuffle | ||||||
|  |  | ||||||
| from bootstrap_datepicker_plus.widgets import DateTimePickerInput | from bootstrap_datepicker_plus.widgets import DateTimePickerInput | ||||||
|  | from crispy_forms.helper import FormHelper | ||||||
| from django import forms | from django import forms | ||||||
| from django.forms.widgets import NumberInput | from django.forms.widgets import NumberInput | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from member.models import Club | from member.models import Club | ||||||
| from note_kfet.inputs import Autocomplete | from note_kfet.inputs import Autocomplete, AmountInput | ||||||
| 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 Food, BasicFood, TransformedFood, QRCode | from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order | ||||||
|  |  | ||||||
|  |  | ||||||
| class QRCodeForms(forms.ModelForm): | class QRCodeForms(forms.ModelForm): | ||||||
| @@ -185,3 +186,60 @@ ManageIngredientsFormSet = forms.formset_factory( | |||||||
|     ManageIngredientsForm, |     ManageIngredientsForm, | ||||||
|     extra=1, |     extra=1, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishForm(forms.ModelForm): | ||||||
|  |     """ | ||||||
|  |     Form to create a dish | ||||||
|  |     """ | ||||||
|  |     class Meta: | ||||||
|  |         model = Dish | ||||||
|  |         fields = ('main', 'price', 'available') | ||||||
|  |         widgets = { | ||||||
|  |             "price": AmountInput(), | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SupplementForm(forms.ModelForm): | ||||||
|  |     """ | ||||||
|  |     Form to create a dish | ||||||
|  |     """ | ||||||
|  |     class Meta: | ||||||
|  |         model = Supplement | ||||||
|  |         fields = '__all__' | ||||||
|  |         widgets = { | ||||||
|  |             "price": AmountInput(), | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # The 2 following classes are copied from treasury app | ||||||
|  | # Add a subform per supplement in the dish form, and manage correctly the link between the dish and | ||||||
|  | # its supplements. The FormSet will search automatically the ForeignKey in the Supplement model. | ||||||
|  | SupplementFormSet = forms.inlineformset_factory( | ||||||
|  |     Dish, | ||||||
|  |     Supplement, | ||||||
|  |     form=SupplementForm, | ||||||
|  |     extra=0, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SupplementFormSetHelper(FormHelper): | ||||||
|  |     """ | ||||||
|  |     Specify some template information for the supplement form | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__(self, form=None): | ||||||
|  |         super().__init__(form) | ||||||
|  |         self.form_tag = False | ||||||
|  |         self.form_method = 'POST' | ||||||
|  |         self.form_class = 'form-inline' | ||||||
|  |         self.template = 'bootstrap4/table_inline_formset.html' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OrderForm(forms.ModelForm): | ||||||
|  |     """ | ||||||
|  |     Form to order food | ||||||
|  |     """ | ||||||
|  |     class Meta: | ||||||
|  |         model = Order | ||||||
|  |         exclude = ("activity", "number", "ordered_at", "served", "served_at") | ||||||
|   | |||||||
| @@ -0,0 +1,23 @@ | |||||||
|  | # Generated by Django 5.2.4 on 2025-08-30 00:16 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ('food', '0001_initial'), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name='food', | ||||||
|  |             name='end_of_life', | ||||||
|  |             field=models.CharField(blank=True, max_length=255, verbose_name='end of life'), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name='food', | ||||||
|  |             name='order', | ||||||
|  |             field=models.CharField(blank=True, max_length=255, verbose_name='order'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -0,0 +1,86 @@ | |||||||
|  | # Generated by Django 5.2.6 on 2025-10-30 22:46 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | import django.utils.timezone | ||||||
|  | from django.conf import settings | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ('activity', '0007_alter_guest_activity'), | ||||||
|  |         ('food', '0002_alter_food_end_of_life_alter_food_order'), | ||||||
|  |         ('note', '0007_alter_note_polymorphic_ctype_and_more'), | ||||||
|  |         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name='Dish', | ||||||
|  |             fields=[ | ||||||
|  |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||||
|  |                 ('price', models.PositiveIntegerField(verbose_name='price')), | ||||||
|  |                 ('available', models.BooleanField(default=True, verbose_name='available')), | ||||||
|  |                 ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dishes', to='activity.activity', verbose_name='activity')), | ||||||
|  |                 ('main', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dishes_as_main', to='food.transformedfood', verbose_name='main food')), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 'verbose_name': 'Dish', | ||||||
|  |                 'verbose_name_plural': 'Dishes', | ||||||
|  |                 'unique_together': {('main', 'activity')}, | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name='Order', | ||||||
|  |             fields=[ | ||||||
|  |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||||
|  |                 ('request', models.TextField(blank=True, help_text='A specific request (to remove an ingredient for example)', verbose_name='request')), | ||||||
|  |                 ('number', models.PositiveIntegerField(default=1, verbose_name='number')), | ||||||
|  |                 ('ordered_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='order date')), | ||||||
|  |                 ('served', models.BooleanField(default=False, verbose_name='served')), | ||||||
|  |                 ('served_at', models.DateTimeField(blank=True, null=True, verbose_name='served date')), | ||||||
|  |                 ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to='activity.activity', verbose_name='activity')), | ||||||
|  |                 ('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='food.dish', verbose_name='dish')), | ||||||
|  |                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to=settings.AUTH_USER_MODEL, verbose_name='user')), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 'verbose_name': 'Order', | ||||||
|  |                 'verbose_name_plural': 'Orders', | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name='FoodTransaction', | ||||||
|  |             fields=[ | ||||||
|  |                 ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.transaction')), | ||||||
|  |                 ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='food.order', verbose_name='order')), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 'verbose_name': 'food transaction', | ||||||
|  |                 'verbose_name_plural': 'food transactions', | ||||||
|  |             }, | ||||||
|  |             bases=('note.transaction',), | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name='Supplement', | ||||||
|  |             fields=[ | ||||||
|  |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||||
|  |                 ('price', models.PositiveIntegerField(verbose_name='price')), | ||||||
|  |                 ('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supplements', to='food.dish', verbose_name='dish')), | ||||||
|  |                 ('food', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='supplements', to='food.food', verbose_name='food')), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 'verbose_name': 'Supplement', | ||||||
|  |                 'verbose_name_plural': 'Supplements', | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name='order', | ||||||
|  |             name='supplements', | ||||||
|  |             field=models.ManyToManyField(blank=True, related_name='orders', to='food.supplement', verbose_name='supplements'), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterUniqueTogether( | ||||||
|  |             name='order', | ||||||
|  |             unique_together={('activity', 'number')}, | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -4,10 +4,14 @@ | |||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
|  |  | ||||||
| from django.db import models, transaction | from django.db import models, transaction | ||||||
|  | from django.core.exceptions import ValidationError | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
|  | from django.contrib.auth.models import User | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from polymorphic.models import PolymorphicModel | from polymorphic.models import PolymorphicModel | ||||||
| from member.models import Club | from member.models import Club | ||||||
|  | from activity.models import Activity | ||||||
|  | from note.models import Transaction | ||||||
|  |  | ||||||
|  |  | ||||||
| class Allergen(models.Model): | class Allergen(models.Model): | ||||||
| @@ -284,3 +288,199 @@ class QRCode(models.Model): | |||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return _('QR-code number') + ' ' + str(self.qr_code_number) |         return _('QR-code number') + ' ' + str(self.qr_code_number) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Dish(models.Model): | ||||||
|  |     """ | ||||||
|  |     A dish is a food proposed during a meal | ||||||
|  |     """ | ||||||
|  |     main = models.ForeignKey( | ||||||
|  |         TransformedFood, | ||||||
|  |         on_delete=models.PROTECT, | ||||||
|  |         related_name='dishes_as_main', | ||||||
|  |         verbose_name=_('main food'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     price = models.PositiveIntegerField( | ||||||
|  |         verbose_name=_('price') | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     activity = models.ForeignKey( | ||||||
|  |         Activity, | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         related_name='dishes', | ||||||
|  |         verbose_name=_('activity'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     available = models.BooleanField( | ||||||
|  |         default=True, | ||||||
|  |         verbose_name=_('available'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _('Dish') | ||||||
|  |         verbose_name_plural = _('Dishes') | ||||||
|  |         unique_together = ('main', 'activity') | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return self.main.name + ' (' + str(self.activity) + ')' | ||||||
|  |  | ||||||
|  |     def save(self, *args, **kwargs): | ||||||
|  |         "Check the type of activity" | ||||||
|  |         if self.activity.activity_type.name != 'Perm bouffe': | ||||||
|  |             raise ValidationError(_('(You cannot select this type of activity.')) | ||||||
|  |  | ||||||
|  |         return super().save(*args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Supplement(models.Model): | ||||||
|  |     """ | ||||||
|  |     A supplement is a food added to a dish | ||||||
|  |     """ | ||||||
|  |     dish = models.ForeignKey( | ||||||
|  |         Dish, | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         related_name='supplements', | ||||||
|  |         verbose_name=_('dish'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     food = models.ForeignKey( | ||||||
|  |         Food, | ||||||
|  |         on_delete=models.PROTECT, | ||||||
|  |         related_name='supplements', | ||||||
|  |         verbose_name=_('food'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     price = models.PositiveIntegerField( | ||||||
|  |         verbose_name=_('price') | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _('Supplement') | ||||||
|  |         verbose_name_plural = _('Supplements') | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return _("Supplement {food} for {dish}").format( | ||||||
|  |             food=str(self.food), dish=str(self.dish)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Order(models.Model): | ||||||
|  |     """ | ||||||
|  |     An order is a dish ordered by a member during an activity | ||||||
|  |     """ | ||||||
|  |     user = models.ForeignKey( | ||||||
|  |         User, | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         related_name='food_orders', | ||||||
|  |         verbose_name=_('user'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     activity = models.ForeignKey( | ||||||
|  |         Activity, | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         related_name='food_orders', | ||||||
|  |         verbose_name=_('activity'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     dish = models.ForeignKey( | ||||||
|  |         Dish, | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |         related_name='orders', | ||||||
|  |         verbose_name=_('dish'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     supplements = models.ManyToManyField( | ||||||
|  |         Supplement, | ||||||
|  |         related_name='orders', | ||||||
|  |         verbose_name=_('supplements'), | ||||||
|  |         blank=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     request = models.TextField( | ||||||
|  |         blank=True, | ||||||
|  |         verbose_name=_('request'), | ||||||
|  |         help_text=_('A specific request (to remove an ingredient for example)') | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     number = models.PositiveIntegerField( | ||||||
|  |         verbose_name=_('number'), | ||||||
|  |         default=1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     ordered_at = models.DateTimeField( | ||||||
|  |         default=timezone.now, | ||||||
|  |         verbose_name=_('order date'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     served = models.BooleanField( | ||||||
|  |         default=False, | ||||||
|  |         verbose_name=_('served'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     served_at = models.DateTimeField( | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |         verbose_name=_('served date'), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _('Order') | ||||||
|  |         verbose_name_plural = _('Orders') | ||||||
|  |         unique_together = ('activity', 'number', ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def amount(self): | ||||||
|  |         return self.dish.price + sum(s.price for s in self.supplements.all()) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return _("Order of {dish} by {user}").format( | ||||||
|  |             dish=str(self.dish), | ||||||
|  |             user=str(self.user)) | ||||||
|  |  | ||||||
|  |     def save(self, *args, **kwargs): | ||||||
|  |         created = self.pk is None | ||||||
|  |         if created: | ||||||
|  |             last_order = Order.objects.filter(activity=self.activity).last() | ||||||
|  |             if last_order is None: | ||||||
|  |                 self.number = 1 | ||||||
|  |             else: | ||||||
|  |                 self.number = last_order.number + 1 | ||||||
|  |  | ||||||
|  |         elif self.served: | ||||||
|  |             if FoodTransaction.objects.filter(order=self).exists(): | ||||||
|  |                 transaction = FoodTransaction.objects.get(order=self) | ||||||
|  |                 transaction.valid = True | ||||||
|  |                 transaction.save() | ||||||
|  |             else: | ||||||
|  |                 transaction = FoodTransaction( | ||||||
|  |                     source=self.user.note, | ||||||
|  |                     destination=self.activity.organizer.note, | ||||||
|  |                     amount=self.amount, | ||||||
|  |                     quantity=1, | ||||||
|  |                     valid=True, | ||||||
|  |                     order=self, | ||||||
|  |                 ) | ||||||
|  |                 transaction.save() | ||||||
|  |         else: | ||||||
|  |             if FoodTransaction.objects.filter(order=self).exists(): | ||||||
|  |                 transaction = FoodTransaction.objects.get(order=self) | ||||||
|  |                 transaction.valid = False | ||||||
|  |                 transaction.save() | ||||||
|  |  | ||||||
|  |         return super().save(*args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FoodTransaction(Transaction): | ||||||
|  |     """ | ||||||
|  |     Special type of :model:`note.Transaction` associated to a :model:`food.Order`. | ||||||
|  |     """ | ||||||
|  |     order = models.ForeignKey( | ||||||
|  |         Order, | ||||||
|  |         on_delete=models.PROTECT, | ||||||
|  |         related_name='transaction', | ||||||
|  |         verbose_name=_('order') | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         verbose_name = _("food transaction") | ||||||
|  |         verbose_name_plural = _("food transactions") | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								apps/food/static/food/js/order.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								apps/food/static/food/js/order.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | /** | ||||||
|  |  * On click of "delete", delete the order | ||||||
|  |  * @param button_id:Integer Order id to remove | ||||||
|  |  * @param table_id: Id of the table to reload | ||||||
|  |  */ | ||||||
|  | function delete_button (button_id, table_id) { | ||||||
|  |   $.ajax({ | ||||||
|  |     url: '/api/food/order/' + button_id + '/', | ||||||
|  |     method: 'DELETE', | ||||||
|  |     headers: { 'X-CSRFTOKEN': CSRF_TOKEN } | ||||||
|  |   }).done(function () { | ||||||
|  |     $('#' + table_id).load(location.pathname + ' #' + table_id + ' > *') | ||||||
|  |   }).fail(function (xhr, _textStatus, _error) { | ||||||
|  |     errMsg(xhr.responseJSON, 10000) | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * On click of "Serve", mark the order as served | ||||||
|  |  * @param button_id: Order id | ||||||
|  |  * @param table_id:  Id of the table to reload | ||||||
|  |  */ | ||||||
|  | function serve_button(button_id, table_id, current_state) { | ||||||
|  |     console.log("update") | ||||||
|  |   const new_state = !current_state; | ||||||
|  |   $.ajax({ | ||||||
|  |     url: '/api/food/order/' + button_id + '/', | ||||||
|  |     method: 'PATCH', | ||||||
|  |     headers: { 'X-CSRFTOKEN': CSRF_TOKEN }, | ||||||
|  |     contentType: 'application/json', | ||||||
|  |     data: JSON.stringify({ | ||||||
|  |       served: new_state | ||||||
|  |     }) | ||||||
|  |   }) | ||||||
|  |   .done(function () { | ||||||
|  |     if (current_state) { | ||||||
|  |     $('table').load(location.pathname + ' table') | ||||||
|  |     } | ||||||
|  |     else { | ||||||
|  |     $('#' + table_id).load(location.pathname + ' #' + table_id + ' > *'); | ||||||
|  |     } | ||||||
|  |   }) | ||||||
|  |   .fail(function (xhr) { | ||||||
|  |     errMsg(xhr.responseJSON, 10000); | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -2,20 +2,120 @@ | |||||||
| # SPDX-License-Identifier: GPL-3.0-or-later | # SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  |  | ||||||
| import django_tables2 as tables | import django_tables2 as tables | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from note_kfet.middlewares import get_current_request | ||||||
|  | from note.templatetags.pretty_money import pretty_money | ||||||
|  | from permission.backends import PermissionBackend | ||||||
|  |  | ||||||
| from .models import Food | from .models import Food, Dish, Order | ||||||
|  |  | ||||||
|  |  | ||||||
| class FoodTable(tables.Table): | class FoodTable(tables.Table): | ||||||
|     """ |     """ | ||||||
|     List all foods. |     List all foods. | ||||||
|     """ |     """ | ||||||
|  |     qr_code_numbers = tables.Column(empty_values=(), verbose_name=_("QR Codes"), orderable=False) | ||||||
|  |  | ||||||
|  |     date = tables.Column(empty_values=(), verbose_name=_("Arrival/creation date"), orderable=False) | ||||||
|  |  | ||||||
|  |     def render_date(self, record): | ||||||
|  |         if record.__class__.__name__ == "BasicFood": | ||||||
|  |             return record.arrival_date.strftime("%d/%m/%Y %H:%M") | ||||||
|  |         elif record.__class__.__name__ == "TransformedFood": | ||||||
|  |             return record.creation_date.strftime("%d/%m/%Y %H:%M") | ||||||
|  |         else: | ||||||
|  |             return "--" | ||||||
|  |  | ||||||
|  |     def render_qr_code_numbers(self, record): | ||||||
|  |         return ", ".join(str(q.qr_code_number) for q in record.QR_code.all()) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Food |         model = Food | ||||||
|         template_name = 'django_tables2/bootstrap4.html' |         template_name = 'django_tables2/bootstrap4.html' | ||||||
|         fields = ('name', 'owner', 'allergens', 'expiry_date') |         fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date') | ||||||
|         row_attrs = { |         row_attrs = { | ||||||
|             'class': 'table-row', |             'class': 'table-row', | ||||||
|             'data-href': lambda record: 'detail/' + str(record.pk), |             'data-href': lambda record: 'detail/' + str(record.pk), | ||||||
|             'style': 'cursor:pointer', |             'style': 'cursor:pointer', | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishTable(tables.Table): | ||||||
|  |     """ | ||||||
|  |     List dishes | ||||||
|  |     """ | ||||||
|  |     supplements = tables.Column(empty_values=(), verbose_name=_('Available supplements'), orderable=False) | ||||||
|  |  | ||||||
|  |     def render_supplements(self, record): | ||||||
|  |         return ", ".join(str(q.food) for q in record.supplements.all()) | ||||||
|  |  | ||||||
|  |     def render_price(self, value): | ||||||
|  |         return pretty_money(value) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = Dish | ||||||
|  |         template_name = 'django_tables2/bootstrap4.html' | ||||||
|  |         fields = ('main', 'supplements', 'price', 'available') | ||||||
|  |         row_attrs = { | ||||||
|  |             'class': 'table-row', | ||||||
|  |             'data-href': lambda record: str(record.pk), | ||||||
|  |             'style': 'cursor:pointer', | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | DELETE_TEMPLATE = """ | ||||||
|  | <button id="{{ record.pk }}" | ||||||
|  |         class="btn btn-danger btn-sm" | ||||||
|  |         onclick="delete_button(this.id, 'orders_table_{{ table.prefix }}')"> | ||||||
|  |     {{ delete_trans }} | ||||||
|  | </button> | ||||||
|  | """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | SERVE_TEMPLATE = """ | ||||||
|  | <button id="{{ record.pk }}" | ||||||
|  |         class="btn btn-sm {% if record.served %}btn-secondary{% else %}btn-success{% endif %}" | ||||||
|  |         onclick="serve_button(this.id, 'orders_table_{{ table.prefix }}', {{ record.served|yesno:'true,false' }})"> | ||||||
|  |     {% if record.served %} | ||||||
|  |         {{ record.served_at|date:"d/m/Y H:i" }} | ||||||
|  |     {% else %}""" + _('Serve') + """ | ||||||
|  |     {% endif %} | ||||||
|  | </button> | ||||||
|  | """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OrderTable(tables.Table): | ||||||
|  |     """ | ||||||
|  |     Lis all orders. | ||||||
|  |     """ | ||||||
|  |     delete = tables.TemplateColumn( | ||||||
|  |         template_code=DELETE_TEMPLATE, | ||||||
|  |         extra_context={"delete_trans": _('Delete')}, | ||||||
|  |         orderable=False, | ||||||
|  |         attrs={'td': {'class': lambda record: 'col-sm-1' + ( | ||||||
|  |             ' d-none' if not PermissionBackend.check_perm( | ||||||
|  |                 get_current_request(), "food.delete_order", | ||||||
|  |                 record) else '')}}, verbose_name=_("Delete"), ) | ||||||
|  |  | ||||||
|  |     serve = tables.TemplateColumn( | ||||||
|  |         template_code=SERVE_TEMPLATE, | ||||||
|  |         extra_context={"serve_trans": _('Serve')}, | ||||||
|  |         orderable=False, | ||||||
|  |         attrs={'td': {'class': lambda record: 'col-sm-1' + ( | ||||||
|  |             ' d-none' if not PermissionBackend.check_perm( | ||||||
|  |                 get_current_request(), "food.change_order_saved", | ||||||
|  |                 record) else '')}}, verbose_name=_("Serve"), ) | ||||||
|  |  | ||||||
|  |     request = tables.Column( | ||||||
|  |         orderable=False | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = Order | ||||||
|  |         template_name = 'django_tables2/bootstrap4.html' | ||||||
|  |         fields = ('ordered_at', 'user', 'dish', 'supplements', 'request', 'serve', 'delete') | ||||||
|  |         order_by = ('ordered_at', ) | ||||||
|  |         row_attrs = { | ||||||
|  |             'class': 'table-row', | ||||||
|  |             'style': 'cursor:pointer', | ||||||
|  |         } | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								apps/food/templates/food/dish_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								apps/food/templates/food/dish_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load i18n crispy_forms_tags %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |     <div class="card bg-light"> | ||||||
|  |         <div class="card-header text-center"> | ||||||
|  |             <h4>{% trans "Delete dish" %}</h4> | ||||||
|  |         </div> | ||||||
|  |         <div class="card-body"> | ||||||
|  |             <div class="alert alert-warning"> | ||||||
|  |                 {% blocktrans %}Are you sure you want to delete this dish? This action can't be undone.{% endblocktrans %} | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="card-footer text-center"> | ||||||
|  |             <form method="post"> | ||||||
|  |                 {% csrf_token %} | ||||||
|  |                 <a class="btn btn-primary" href="{% url 'food:dish_detail' activity_pk=object.activity.pk pk=object.pk%}">{% trans "Return to dish detail" %}</a> | ||||||
|  |                 <button class="btn btn-danger" type="submit">{% trans "Delete" %}</button> | ||||||
|  |             </form> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										41
									
								
								apps/food/templates/food/dish_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								apps/food/templates/food/dish_detail.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load render_table from django_tables2 %} | ||||||
|  | {% load i18n pretty_money %} | ||||||
|  |  | ||||||
|  | {% 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> {% trans "Associated food" %} :  | ||||||
|  |         <a href="{% url "food:transformedfood_view" pk=food.pk %}"> | ||||||
|  |           {{ food.name }} | ||||||
|  |         </a> | ||||||
|  |       </li> | ||||||
|  |       <li> {% trans "Sell price" %} : {{ dish.price|pretty_money }}</li> | ||||||
|  |       <li> {% trans "Available" %} : {{ dish.available|yesno }}</li> | ||||||
|  |       <li> {% trans "Possible supplements" %} :  | ||||||
|  |         {% for supp in supplements %} | ||||||
|  |           <a href="{% url "food:food_view" pk=supp.food.pk %}">{{ supp.food.name }} ({{ supp.price|pretty_money }})</a>{% if not forloop.last %},{% endif %} | ||||||
|  |         {% endfor %} | ||||||
|  |       </li> | ||||||
|  |     </ul> | ||||||
|  |     {% if update %} | ||||||
|  |         <a class="btn btn-sm btn-secondary" href="{% url "food:dish_update" activity_pk=dish.activity.pk pk=dish.pk %}"> | ||||||
|  |           {% trans "Update" %} | ||||||
|  |         </a> | ||||||
|  |     {% endif %} | ||||||
|  |     {% if delete %} | ||||||
|  |     <a class="btn btn-sm btn-danger" href="{% url "food:dish_delete" activity_pk=dish.activity.pk pk=dish.pk %}"> | ||||||
|  |           {% trans "Delete" %} | ||||||
|  |         </a> | ||||||
|  |     {% endif %} | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										94
									
								
								apps/food/templates/food/dish_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								apps/food/templates/food/dish_form.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | |||||||
|  | {% 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> | ||||||
|  |   <form method="post" action=""> | ||||||
|  |     {% csrf_token %} | ||||||
|  |     <div class="card-body"> | ||||||
|  |       {% crispy form %} | ||||||
|  |     </div> | ||||||
|  |   <h3 class="card-header text-center"> | ||||||
|  |     {% trans "Ajouter des suppléments (optionnel)" %} | ||||||
|  |   </h3> | ||||||
|  |   {{ formset.management_form }} | ||||||
|  |   <table class="table table-condensed table-striped"> | ||||||
|  |     {% for form in formset %} | ||||||
|  |     {% if forloop.first %} | ||||||
|  |     <thead> | ||||||
|  |       <tr> | ||||||
|  |         <th>{{ form.food.label }}<span class="asteriskField">*</span></th> | ||||||
|  |         <th>{{ form.price.label }}<span class="asteriskField">*</span></th> | ||||||
|  |       </tr> | ||||||
|  |     </thead> | ||||||
|  |     <tbody id="form_body"> | ||||||
|  |       {% endif %} | ||||||
|  |       <tr class="row-formset"> | ||||||
|  |         <td>{{ form.food }}</td> | ||||||
|  |         <td>{{ form.price }}</td> | ||||||
|  |         {# These fields are hidden but handled by the formset to link the id and the invoice id #} | ||||||
|  |         {{ form.dish }} | ||||||
|  |         {{ form.id }} | ||||||
|  |       </tr> | ||||||
|  |       {% endfor %} | ||||||
|  |     </tbody> | ||||||
|  |   </table> | ||||||
|  |  | ||||||
|  |   {# Display buttons to add and remove supplements #} | ||||||
|  |     <div class="card-body"> | ||||||
|  |         <div class="btn-group btn-block" role="group"> | ||||||
|  |             <button type="button" id="add_more" class="btn btn-success">{% trans "Add supplement" %}</button> | ||||||
|  |             <button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove supplement" %}</button> | ||||||
|  |         </div> | ||||||
|  |         <button type="submit" class="btn btn-block btn-primary">{% trans "Submit" %}</button> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {# Hidden div that store an empty supplement 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.food }}</td> | ||||||
|  |                 <td>{{ formset.empty_form.price }} </td> | ||||||
|  |                 {{ formset.empty_form.dish }} | ||||||
|  |                 {{ formset.empty_form.id }} | ||||||
|  |             </tr> | ||||||
|  |         </tbody> | ||||||
|  |     </table> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block extrajavascript %} | ||||||
|  | <script> | ||||||
|  |     /* script that handles add and remove lines */ | ||||||
|  |     IDS = {}; | ||||||
|  |  | ||||||
|  |     $("#id_supplements-TOTAL_FORMS").val($(".row-formset").length - 1); | ||||||
|  |  | ||||||
|  |     $('#add_more').click(function () { | ||||||
|  |         let form_idx = $('#id_supplements-TOTAL_FORMS').val(); | ||||||
|  |         $('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx)); | ||||||
|  |         $('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) + 1); | ||||||
|  |         $('#id_supplements-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     $('#remove_one').click(function () { | ||||||
|  |         let form_idx = $('#id_supplements-TOTAL_FORMS').val(); | ||||||
|  |         if (form_idx > 0) { | ||||||
|  |             IDS[parseInt(form_idx) - 1] = $('#id_supplements-' + (parseInt(form_idx) - 1) + '-id').val(); | ||||||
|  |             $('#form_body tr:last-child').remove(); | ||||||
|  |             $('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) - 1); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | </script> | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										33
									
								
								apps/food/templates/food/dish_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								apps/food/templates/food/dish_list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load render_table from django_tables2 %} | ||||||
|  | {% load i18n %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  | <div class="card bg-white mb-3"> | ||||||
|  |     <h3 class="card-header text-center"> | ||||||
|  |         {{ title }} {{activity.name}} | ||||||
|  |     </h3> | ||||||
|  |     {% render_table table %} | ||||||
|  |     <div class="card-footer"> | ||||||
|  |         {% if can_add_dish %} | ||||||
|  |         <a class="btn btn-sm btn-success" href="{% url 'food:dish_create' activity_pk=activity.pk %}">{% trans "New dish" %}</a> | ||||||
|  |         {% endif %} | ||||||
|  |         <a class="btn btn-sm btn-secondary" href="{% url 'activity:activity_detail' pk=activity.pk %}">{% trans "Activity page" %}</a> | ||||||
|  |         <a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}"> | ||||||
|  |           {% trans "Return to the food list" %} | ||||||
|  |         </a> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block extrajavascript %} | ||||||
|  | <script type="text/javascript"> | ||||||
|  |     $(".table-row").click(function () { | ||||||
|  |             window.document.location = $(this).data("href"); | ||||||
|  |         }); | ||||||
|  | </script> | ||||||
|  | {% endblock %} | ||||||
| @@ -34,6 +34,12 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   <div class="card-body"> |   <div class="card-body"> | ||||||
|  |     <div class="form-check"> | ||||||
|  |       <label for="stock_only" class="form-check-label"> | ||||||
|  |           <input id="stock_only" name="stock_only" type="checkbox" class="checkboxinput form-check-input" checked> | ||||||
|  |           {% trans "Filter with only food in stock" %} | ||||||
|  |       </label> | ||||||
|  |     </div> | ||||||
|       <input id="searchbar" type="text" class="form-control" |       <input id="searchbar" type="text" class="form-control" | ||||||
|           placeholder="{% trans "Search by attribute such as name..." %}"> |           placeholder="{% trans "Search by attribute such as name..." %}"> | ||||||
|   </div> |   </div> | ||||||
| @@ -58,13 +64,19 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|   <h3 class="card-header text-center"> |   <h3 class="card-header text-center"> | ||||||
|     {% trans "Meal served" %} |     {% trans "Meal served" %} | ||||||
|   </h3> |   </h3> | ||||||
|   {% if can_add_meal %} |  | ||||||
|   <div class="card-footer"> |   <div class="card-footer"> | ||||||
|  |     {% if can_add_meal %} | ||||||
|     <a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}"> |     <a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}"> | ||||||
|       {% trans "New meal" %} |       {% trans "New meal" %} | ||||||
|     </a> |     </a> | ||||||
|  |     {% endif %} | ||||||
|  |     {% for activity in open_activities %} | ||||||
|  |       <a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> | ||||||
|  |       {% trans "View" %} {{ activity.name }} | ||||||
|  |     </a> | ||||||
|  |     {% endfor %} | ||||||
|   </div> |   </div> | ||||||
|   {% endif %} |  | ||||||
|   {% if served.data %} |   {% if served.data %} | ||||||
|   {% render_table served %} |   {% render_table served %} | ||||||
|   {% else %} |   {% else %} | ||||||
| @@ -114,7 +126,26 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|   {% endif %} |   {% endif %} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | <script type="text/javascript"> | ||||||
|  |  | ||||||
|  |     let old_pattern = null; | ||||||
|  |     let searchbar_obj = $("#searchbar"); | ||||||
|  |     let stock_only_obj = $("#stock_only"); | ||||||
|  |  | ||||||
|  |     function reloadTable() { | ||||||
|  |         let pattern = searchbar_obj.val(); | ||||||
|  |  | ||||||
|  |         $("#dynamic-table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + ( | ||||||
|  |             stock_only_obj.is(':checked') ? "" : "&stock=1") + " #dynamic-table"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     searchbar_obj.keyup(reloadTable); | ||||||
|  |     stock_only_obj.change(reloadTable); | ||||||
|  |  | ||||||
|  |     $(document).on("click", ".table-row", function () { | ||||||
|  |         window.document.location = $(this).data("href"); | ||||||
|  |     }); | ||||||
|  | </script> | ||||||
| <script> | <script> | ||||||
|   document.addEventListener('DOMContentLoaded', function() { |   document.addEventListener('DOMContentLoaded', function() { | ||||||
|       document.getElementById('goButton').addEventListener('click', function(event) { |       document.getElementById('goButton').addEventListener('click', function(event) { | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								apps/food/templates/food/order_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								apps/food/templates/food/order_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load i18n crispy_forms_tags %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |     <div class="card bg-light"> | ||||||
|  |         <div class="card-header text-center"> | ||||||
|  |             <h4>{% trans "Delete order" %}</h4> | ||||||
|  |         </div> | ||||||
|  |         <div class="card-body"> | ||||||
|  |             <div class="alert alert-warning"> | ||||||
|  |                 {% blocktrans %}Are you sure you want to delete this order? This action can't be undone.{% endblocktrans %} | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="card-footer text-center"> | ||||||
|  |             <form method="post"> | ||||||
|  |                 {% csrf_token %} | ||||||
|  |                 <a class="btn btn-primary" href="{% url 'food:order_list' activity_pk=object.activity.pk%}">{% trans "Return to order list" %}</a> | ||||||
|  |                 <button class="btn btn-danger" type="submit">{% trans "Delete" %}</button> | ||||||
|  |             </form> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										21
									
								
								apps/food/templates/food/order_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								apps/food/templates/food/order_form.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load i18n crispy_forms_tags %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  | <div class="card bg-white mb-3"> | ||||||
|  |   <h3 class="card-header text-center"> | ||||||
|  |     {{ title }} | ||||||
|  |   </h3> | ||||||
|  |   <div class="card-body" id="form"> | ||||||
|  |     <form method="post"> | ||||||
|  |       {% csrf_token %} | ||||||
|  |       {{ form | crispy }} | ||||||
|  |       <button class="btn btn-primary" type="submit">{% trans "Submit"%}</button> | ||||||
|  |     </form> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										30
									
								
								apps/food/templates/food/order_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								apps/food/templates/food/order_list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load render_table from django_tables2 %} | ||||||
|  | {% load static i18n %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  | <div class="card bg-white mb-3"> | ||||||
|  |     <h3 class="card-header text-center"> | ||||||
|  |         {{ title }} | ||||||
|  |     </h3> | ||||||
|  |     <a class="btn btn-primary" href="{% url 'food:served_order_list' activity_pk=activity.pk %}">{% trans "View served orders" %}</a> | ||||||
|  |       {% for table in tables %} | ||||||
|  |         <div class="card bg-light mb-3" id="orders_table_{{ table.prefix }}"> | ||||||
|  |         <h3 class="card-header text-center"> | ||||||
|  |             {% trans "Orders of " %} {{ table.prefix }}  | ||||||
|  |         </h3> | ||||||
|  |         {% if table.data %} | ||||||
|  |             {% render_table table %} | ||||||
|  |         {% endif %} | ||||||
|  |         </div> | ||||||
|  |       {% endfor %} | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block extrajavascript %} | ||||||
|  | <script src="{% static "food/js/order.js" %}"></script> | ||||||
|  | {% endblock%} | ||||||
							
								
								
									
										21
									
								
								apps/food/templates/food/served_order_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								apps/food/templates/food/served_order_list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load render_table from django_tables2 %} | ||||||
|  | {% load static i18n %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  | <div class="card bg-white mb-3"> | ||||||
|  |     <h3 class="card-header text-center"> | ||||||
|  |         {{ title }} {{activity.name}} | ||||||
|  |     </h3> | ||||||
|  |     <a class="btn btn-primary" href="{% url 'food:order_list' activity_pk=activity.pk %}">{% trans "View unserved orders" %}</a> | ||||||
|  |     {% render_table table %} | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block extrajavascript %} | ||||||
|  | <script src="{% static "food/js/order.js" %}"></script> | ||||||
|  | {% endblock%} | ||||||
							
								
								
									
										17
									
								
								apps/food/templates/food/supplement_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/food/templates/food/supplement_detail.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  | {% comment %} | ||||||
|  | Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||||
|  | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | {% endcomment %} | ||||||
|  | {% load render_table from django_tables2 %} | ||||||
|  | {% load i18n pretty_money %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  | <div class="card bg-white mb-3"> | ||||||
|  |   <h3 class="card-header text-center"> | ||||||
|  |     {{ title }} {{ supplement.name }} | ||||||
|  |   </h3> | ||||||
|  |   <div class="card-body"> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
| @@ -19,4 +19,14 @@ urlpatterns = [ | |||||||
|     path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'), |     path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'), | ||||||
|     path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'), |     path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'), | ||||||
|     path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'), |     path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'), | ||||||
|  |     # TODO not always store activity_pk in url | ||||||
|  |     path('activity/<int:activity_pk>/dishes/add/', views.DishCreateView.as_view(), name='dish_create'), | ||||||
|  |     path('activity/<int:activity_pk>/dishes/', views.DishListView.as_view(), name='dish_list'), | ||||||
|  |     path('activity/<int:activity_pk>/dishes/<int:pk>/', views.DishDetailView.as_view(), name='dish_detail'), | ||||||
|  |     path('activity/<int:activity_pk>/dishes/<int:pk>/update/', views.DishUpdateView.as_view(), name='dish_update'), | ||||||
|  |     path('activity/<int:activity_pk>/dishes/<int:pk>/delete/', views.DishDeleteView.as_view(), name='dish_delete'), | ||||||
|  |     path('activity/<int:activity_pk>/order/', views.OrderCreateView.as_view(), name='order_create'), | ||||||
|  |     path('activity/<int:activity_pk>/orders/', views.OrderListView.as_view(), name='order_list'), | ||||||
|  |     path('activity/<int:activity_pk>/orders/served', views.ServedOrderListView.as_view(), name='served_order_list'), | ||||||
|  |     path('activity/orders/<int:pk>/delete/', views.OrderDeleteView.as_view(), name='order_delete'), | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -4,25 +4,30 @@ | |||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
|  |  | ||||||
| from api.viewsets import is_regex | from api.viewsets import is_regex | ||||||
| from django_tables2.views import MultiTableMixin | from crispy_forms.helper import FormHelper | ||||||
|  | from django_tables2.views import SingleTableView, MultiTableMixin | ||||||
|  | from django.core.exceptions import PermissionDenied | ||||||
| from django.db import transaction | from django.db import transaction | ||||||
| from django.db.models import Q | from django.db.models import Q | ||||||
| from django.http import HttpResponseRedirect, Http404 | from django.http import HttpResponseRedirect, Http404 | ||||||
| from django.views.generic import DetailView, UpdateView, CreateView | from django.views.generic import DetailView, UpdateView, CreateView | ||||||
| from django.views.generic.list import ListView | from django.views.generic.list import ListView | ||||||
| from django.views.generic.base import RedirectView | from django.views.generic.base import RedirectView | ||||||
|  | from django.views.generic.edit import DeleteView | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from member.models import Club, Membership | from member.models import Club, Membership | ||||||
|  | from activity.models import Activity | ||||||
| from permission.backends import PermissionBackend | from permission.backends import PermissionBackend | ||||||
| from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin | from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin | ||||||
|  |  | ||||||
| from .models import Food, BasicFood, TransformedFood, QRCode | from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish | ||||||
| from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ | from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ | ||||||
|     ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ |     ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ | ||||||
|     BasicFoodUpdateForms, TransformedFoodUpdateForms |     BasicFoodUpdateForms, TransformedFoodUpdateForms, \ | ||||||
| from .tables import FoodTable |     DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm | ||||||
|  | from .tables import FoodTable, DishTable, OrderTable | ||||||
| from .utils import pretty_duration | from .utils import pretty_duration | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -65,9 +70,13 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li | |||||||
|             suffix = '__iregex' if valid_regex else '__istartswith' |             suffix = '__iregex' if valid_regex else '__istartswith' | ||||||
|             prefix = '^' if valid_regex else '' |             prefix = '^' if valid_regex else '' | ||||||
|             qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) |             qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) | ||||||
|                            | Q(**{f'owner__name{suffix}': prefix + pattern})) |                            | Q(**{f'owner__name{suffix}': prefix + pattern}) | ||||||
|  |                            | Q(**{f'owner__note__alias__name{suffix}': prefix + pattern})) | ||||||
|         else: |         else: | ||||||
|             qs = qs.none() |             qs = qs.none() | ||||||
|  |         if "stock" not in self.request.GET or not self.request.GET["stock"] == '1': | ||||||
|  |             qs = qs.filter(end_of_life='') | ||||||
|  |  | ||||||
|         search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view')) |         search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view')) | ||||||
|         # table open |         # table open | ||||||
|         open_table = self.get_queryset().order_by('expiry_date').filter( |         open_table = self.get_queryset().order_by('expiry_date').filter( | ||||||
| @@ -95,6 +104,7 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li | |||||||
|                 owner=club, end_of_life='').filter( |                 owner=club, end_of_life='').filter( | ||||||
|                     PermissionBackend.filter_queryset(self.request, Food, 'view') |                     PermissionBackend.filter_queryset(self.request, Food, 'view') | ||||||
|             )) |             )) | ||||||
|  |  | ||||||
|         return [search_table, open_table, served_table] + club_table |         return [search_table, open_table, served_table] + club_table | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs): |     def get_context_data(self, **kwargs): | ||||||
| @@ -107,6 +117,9 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li | |||||||
|         context['club_tables'] = tables[3:] |         context['club_tables'] = tables[3:] | ||||||
|  |  | ||||||
|         context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add') |         context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add') | ||||||
|  |  | ||||||
|  |         context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True) | ||||||
|  |  | ||||||
|         return context |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -218,7 +231,7 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): | |||||||
|         copy = self.request.GET.get('copy', None) |         copy = self.request.GET.get('copy', None) | ||||||
|         if copy is not None: |         if copy is not None: | ||||||
|             food = BasicFood.objects.get(pk=copy) |             food = BasicFood.objects.get(pk=copy) | ||||||
|             print(context['form'].fields) |  | ||||||
|             for field in context['form'].fields: |             for field in context['form'].fields: | ||||||
|                 if field == 'allergens': |                 if field == 'allergens': | ||||||
|                     context['form'].fields[field].initial = getattr(food, field).all() |                     context['form'].fields[field].initial = getattr(food, field).all() | ||||||
| @@ -521,3 +534,270 @@ class QRCodeRedirectView(RedirectView): | |||||||
|         if slug: |         if slug: | ||||||
|             return reverse_lazy('food:qrcode_create', kwargs={'slug': slug}) |             return reverse_lazy('food:qrcode_create', kwargs={'slug': slug}) | ||||||
|         return reverse_lazy('food:list') |         return reverse_lazy('food:list') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishCreateView(ProtectQuerysetMixin, ProtectedCreateView): | ||||||
|  |     """ | ||||||
|  |     Create a dish | ||||||
|  |     """ | ||||||
|  |     model = Dish | ||||||
|  |     form_class = DishForm | ||||||
|  |     extra_context = {"title": _('Create dish')} | ||||||
|  |  | ||||||
|  |     def get_sample_object(self): | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |         sample_food = TransformedFood( | ||||||
|  |             name="Sample food", | ||||||
|  |             owner=activity.organizer, | ||||||
|  |             expiry_date=timezone.now() + timedelta(days=7), | ||||||
|  |             is_ready=True, | ||||||
|  |         ) | ||||||
|  |         sample_dish = Dish( | ||||||
|  |             main=sample_food, | ||||||
|  |             price=100, | ||||||
|  |             activity=activity, | ||||||
|  |         ) | ||||||
|  |         return sample_dish | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs): | ||||||
|  |         context = super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |         form = context['form'] | ||||||
|  |         form.helper = FormHelper() | ||||||
|  |         # Remove form tag on the generation of the form in the template (already present on the template) | ||||||
|  |         form.helper.form_tag = False | ||||||
|  |         # The formset handles the set of the supplements | ||||||
|  |         form_set = SupplementFormSet(instance=form.instance) | ||||||
|  |         context['formset'] = form_set | ||||||
|  |         context['helper'] = SupplementFormSetHelper() | ||||||
|  |  | ||||||
|  |         return context | ||||||
|  |  | ||||||
|  |     def get_form(self, form_class=None): | ||||||
|  |         form = super().get_form(form_class) | ||||||
|  |         if "available" in form.fields: | ||||||
|  |             del form.fields["available"] | ||||||
|  |         return form | ||||||
|  |  | ||||||
|  |     @transaction.atomic | ||||||
|  |     def form_valid(self, form): | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |  | ||||||
|  |         form.instance.activity = activity | ||||||
|  |  | ||||||
|  |         ret = super().form_valid(form) | ||||||
|  |  | ||||||
|  |         # For each supplement, we save it | ||||||
|  |         formset = SupplementFormSet(self.request.POST, instance=form.instance) | ||||||
|  |         if formset.is_valid(): | ||||||
|  |             for f in formset: | ||||||
|  |                 if f.is_valid(): | ||||||
|  |                     f.save() | ||||||
|  |                     f.instance.save() | ||||||
|  |                 else: | ||||||
|  |                     f.instance = None | ||||||
|  |  | ||||||
|  |         return ret | ||||||
|  |  | ||||||
|  |     def get_success_url(self): | ||||||
|  |         return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | ||||||
|  |     """ | ||||||
|  |     List dishes for this activity | ||||||
|  |     """ | ||||||
|  |     model = Dish | ||||||
|  |     table_class = DishTable | ||||||
|  |     extra_context = {"title": _('Dishes served during')} | ||||||
|  |     template_name = 'food/dish_list.html' | ||||||
|  |  | ||||||
|  |     def get_queryset(self): | ||||||
|  |         return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"]) | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs): | ||||||
|  |         context = super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |         context["activity"] = activity | ||||||
|  |  | ||||||
|  |         context["can_add_dish"] = PermissionBackend.check_perm(self.request, 'food.dish_add') | ||||||
|  |  | ||||||
|  |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): | ||||||
|  |     """ | ||||||
|  |     View a dish for this activity | ||||||
|  |     """ | ||||||
|  |     model = Dish | ||||||
|  |     extra_context = {"title": _('Details of:')} | ||||||
|  |     context_oject_name = "dish" | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs): | ||||||
|  |         context = super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |         context["food"] = self.object.main | ||||||
|  |  | ||||||
|  |         context["supplements"] = self.object.supplements.all() | ||||||
|  |  | ||||||
|  |         context["update"] = PermissionBackend.check_perm(self.request, "food.change_dish") | ||||||
|  |  | ||||||
|  |         context["delete"] = not Order.objects.filter(dish=self.get_object()).exists() and PermissionBackend.check_perm(self.request, "food.delete_dish") | ||||||
|  |  | ||||||
|  |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): | ||||||
|  |     """ | ||||||
|  |     A view to update a dish | ||||||
|  |     """ | ||||||
|  |     model = Dish | ||||||
|  |     form_class = DishForm | ||||||
|  |     extra_context = {"title": _("Update a dish")} | ||||||
|  |  | ||||||
|  |     def get_form(self, **kwargs): | ||||||
|  |         form = super().get_form(**kwargs) | ||||||
|  |         if 'main' in form.fields: | ||||||
|  |             del form.fields["main"] | ||||||
|  |         return form | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DishDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): | ||||||
|  |     """ | ||||||
|  |     Delete a dish with no order yet | ||||||
|  |     """ | ||||||
|  |     model = Dish | ||||||
|  |     extra_context = {"title": _('Delete dish')} | ||||||
|  |  | ||||||
|  |     def delete(self, request, *args, **kwargs): | ||||||
|  |         if Order.objects.filter(dish=self.get_object()).exists(): | ||||||
|  |             raise PermissionDenied(_("This dish cannot be deleted because it has already been ordered")) | ||||||
|  |         return super().delete(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  |     def get_success_url(self): | ||||||
|  |         return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OrderCreateView(ProtectQuerysetMixin, ProtectedCreateView): | ||||||
|  |     """ | ||||||
|  |     Order a meal | ||||||
|  |     """ | ||||||
|  |     model = Order | ||||||
|  |     form_class = OrderForm | ||||||
|  |     extra_context = {"title": _('Order food')} | ||||||
|  |  | ||||||
|  |     def get_sample_object(self): | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |         sample_order = Order( | ||||||
|  |             user=self.request.user, | ||||||
|  |             activity=activity, | ||||||
|  |             dish=Dish.objects.filter(activity=activity).last(), | ||||||
|  |         ) | ||||||
|  |         return sample_order | ||||||
|  |  | ||||||
|  |     def get_form(self): | ||||||
|  |         form = super().get_form() | ||||||
|  |  | ||||||
|  |         form.fields["user"].initial = self.request.user | ||||||
|  |         form.fields["user"].disabled = True | ||||||
|  |  | ||||||
|  |         return form | ||||||
|  |  | ||||||
|  |     def form_valid(self, form): | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |  | ||||||
|  |         form.instance.activity = activity | ||||||
|  |  | ||||||
|  |         return super().form_valid(form) | ||||||
|  |  | ||||||
|  |     def get_success_url(self): | ||||||
|  |         return reverse_lazy('food:food_list') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OrderListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): | ||||||
|  |     """ | ||||||
|  |     List existing Families | ||||||
|  |     """ | ||||||
|  |     model = Order | ||||||
|  |     table_class = OrderTable | ||||||
|  |     extra_context = {"title": _('Order list')} | ||||||
|  |     paginate_by = 10 | ||||||
|  |  | ||||||
|  |     def get_queryset(self, **kwargs): | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |         return Order.objects.filter(activity=activity) | ||||||
|  |  | ||||||
|  |     def get_tables(self): | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |         dishes = Dish.objects.filter(activity=activity) | ||||||
|  |  | ||||||
|  |         tables = [OrderTable] * dishes.count() | ||||||
|  |         self.tables = tables | ||||||
|  |         tables = super().get_tables() | ||||||
|  |         for i in range(dishes.count()): | ||||||
|  |             tables[i].prefix = dishes[i].main.name | ||||||
|  |         return tables | ||||||
|  |  | ||||||
|  |     def get_tables_data(self): | ||||||
|  |         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |         dishes = Dish.objects.filter(activity=activity) | ||||||
|  |  | ||||||
|  |         tables = [] | ||||||
|  |  | ||||||
|  |         for dish in dishes: | ||||||
|  |             tables.append(self.get_queryset().order_by('ordered_at').filter( | ||||||
|  |                 dish=dish, served=False).filter( | ||||||
|  |                     PermissionBackend.filter_queryset(self.request, Order, 'view') | ||||||
|  |             )) | ||||||
|  |  | ||||||
|  |         return tables | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs): | ||||||
|  |         context = super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |         context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |  | ||||||
|  |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ServedOrderListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | ||||||
|  |     """ | ||||||
|  |     View served orders | ||||||
|  |     """ | ||||||
|  |     model = Order | ||||||
|  |     template_name = 'food/served_order_list.html' | ||||||
|  |     table_class = OrderTable | ||||||
|  |  | ||||||
|  |     def get_queryset(self): | ||||||
|  |         return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"], served=True).order_by('-served_at') | ||||||
|  |  | ||||||
|  |     def get_table(self, **kwargs): | ||||||
|  |         table = super().get_table(**kwargs) | ||||||
|  |  | ||||||
|  |         table.columns.hide("delete") | ||||||
|  |  | ||||||
|  |         return table | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs): | ||||||
|  |         context = super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |         context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||||
|  |  | ||||||
|  |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OrderDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): | ||||||
|  |     """ | ||||||
|  |     Delete an order | ||||||
|  |     """ | ||||||
|  |     model = Order | ||||||
|  |     extra_context = {"title": _('Delete dish')} | ||||||
|  |  | ||||||
|  |     def delete(self, request, *args, **kwargs): | ||||||
|  |         if self.get_object().served: | ||||||
|  |             raise PermissionDenied(_("This order cannot be deleted because it has already been served")) | ||||||
|  |         return super().delete(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  |     def get_success_url(self): | ||||||
|  |         return reverse_lazy('food:order_list', kwargs={"activity_pk": self.kwargs["activity_pk"]}) | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ from django.contrib.auth.forms import AuthenticationForm | |||||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||||
| from django.db import transaction | from django.db import transaction | ||||||
| from django.forms import CheckboxSelectMultiple | from django.forms import CheckboxSelectMultiple | ||||||
|  | from phonenumber_field.formfields import PhoneNumberField | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from note.models import NoteSpecial, Alias | from note.models import NoteSpecial, Alias | ||||||
| @@ -45,6 +46,11 @@ class ProfileForm(forms.ModelForm): | |||||||
|     A form for the extras field provided by the :model:`member.Profile` model. |     A form for the extras field provided by the :model:`member.Profile` model. | ||||||
|     """ |     """ | ||||||
|     # Remove widget=forms.HiddenInput() if you want to use report frequency. |     # Remove widget=forms.HiddenInput() if you want to use report frequency. | ||||||
|  |     phone_number = PhoneNumberField( | ||||||
|  |         widget=forms.TextInput(attrs={"type": "tel", "class": "form-control"}), | ||||||
|  |         required=False | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency")) |     report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency")) | ||||||
|  |  | ||||||
|     last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date")) |     last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date")) | ||||||
| @@ -72,7 +78,12 @@ class ProfileForm(forms.ModelForm): | |||||||
|         if not self.instance.section or (("department" in self.changed_data |         if not self.instance.section or (("department" in self.changed_data | ||||||
|                                          or "promotion" in self.changed_data) and "section" not in self.changed_data): |                                          or "promotion" in self.changed_data) and "section" not in self.changed_data): | ||||||
|             self.instance.section = self.instance.section_generated |             self.instance.section = self.instance.section_generated | ||||||
|         return super().save(commit) |         instance = super().save(commit=False) | ||||||
|  |         if instance.phone_number: | ||||||
|  |             instance.phone_number = instance.phone_number.as_e164 | ||||||
|  |         if commit: | ||||||
|  |             instance.save() | ||||||
|  |         return instance | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Profile |         model = Profile | ||||||
|   | |||||||
| @@ -92,6 +92,20 @@ class MembershipTable(tables.Table): | |||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     user_email = tables.Column( | ||||||
|  |         verbose_name="Email", | ||||||
|  |         accessor="user.email", | ||||||
|  |         orderable=False, | ||||||
|  |         visible=False, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     user_full_name = tables.Column( | ||||||
|  |         verbose_name=_("Full name"), | ||||||
|  |         accessor="user.get_full_name", | ||||||
|  |         orderable=False, | ||||||
|  |         visible=False, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     def render_user(self, value): |     def render_user(self, value): | ||||||
|         # If the user has the right, link the displayed user with the page of its detail. |         # If the user has the right, link the displayed user with the page of its detail. | ||||||
|         s = value.username |         s = value.username | ||||||
| @@ -149,6 +163,16 @@ class MembershipTable(tables.Table): | |||||||
|                             + "'>" + s + "</a>") |                             + "'>" + s + "</a>") | ||||||
|         return s |         return s | ||||||
|  |  | ||||||
|  |     def value_user(self, record): | ||||||
|  |         return record.user.username if record.user else "" | ||||||
|  |  | ||||||
|  |     def value_club(self, record): | ||||||
|  |         return record.club.name if record.club else "" | ||||||
|  |  | ||||||
|  |     def value_roles(self, record): | ||||||
|  |         roles = record.roles.all() | ||||||
|  |         return ", ".join(str(role) for role in roles) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         attrs = { |         attrs = { | ||||||
|             'class': 'table table-condensed table-striped', |             'class': 'table table-condensed table-striped', | ||||||
|   | |||||||
| @@ -36,7 +36,13 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|             {% trans "There is no membership found with this pattern." %} |             {% trans "There is no membership found with this pattern." %} | ||||||
|         </div> |         </div> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|  |         <div class="card-footer text-center"> | ||||||
|  |             <button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=csv'"> | ||||||
|  |                 {% trans "Export to CSV" %} | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|     </div> |     </div> | ||||||
|  |      | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|         {{ title }} |         {{ title }} | ||||||
|     </h3> |     </h3> | ||||||
|     <div class="card-body"> |     <div class="card-body"> | ||||||
|         <form method="post"> |         <form method="post" id="profile-form"> | ||||||
|             {% csrf_token %} |             {% csrf_token %} | ||||||
|             {{ form | crispy }} |             {{ form | crispy }} | ||||||
|             {{ profile_form | crispy }} |             {{ profile_form | crispy }} | ||||||
| @@ -20,4 +20,46 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|         </form> |         </form> | ||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block extrajavascript %} | ||||||
|  | <!-- intl-tel-input CSS/JS --> | ||||||
|  | <script> | ||||||
|  | (() => { | ||||||
|  |     const input = document.querySelector("input[name='phone_number']"); | ||||||
|  |     const form = document.querySelector("#profile-form"); | ||||||
|  |  | ||||||
|  |     if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const iti = window.intlTelInput(input, { | ||||||
|  |         initialCountry: "auto", | ||||||
|  |         nationalMode: false, | ||||||
|  |         autoPlaceholder: "off", | ||||||
|  |         geoIpLookup: callback => { | ||||||
|  |             fetch("https://ipapi.co/json") | ||||||
|  |                 .then(res => res.json()) | ||||||
|  |                 .then(data => callback(data.country_code)) | ||||||
|  |                 .catch(() => callback("fr")); | ||||||
|  |         }, | ||||||
|  |         loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     form.addEventListener("submit", function(e){ | ||||||
|  |         if (!input.value.trim()) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const number = iti.getNumber(intlTelInput.utils.numberFormat.E164); | ||||||
|  |         if (number) { | ||||||
|  |             input.value = number; | ||||||
|  |             form.submit(); | ||||||
|  |         } else { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             input.focus(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | })(); | ||||||
|  | </script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
| @@ -17,6 +17,7 @@ from django.utils.translation import gettext_lazy as _ | |||||||
| from django.views.generic import DetailView, UpdateView, TemplateView | from django.views.generic import DetailView, UpdateView, TemplateView | ||||||
| from django.views.generic.edit import FormMixin | from django.views.generic.edit import FormMixin | ||||||
| from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView | from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView | ||||||
|  | from django_tables2.export.views import ExportMixin | ||||||
| from rest_framework.authtoken.models import Token | from rest_framework.authtoken.models import Token | ||||||
| from api.viewsets import is_regex | from api.viewsets import is_regex | ||||||
| from note.models import Alias, NoteClub, NoteUser, Trust | from note.models import Alias, NoteClub, NoteUser, Trust | ||||||
| @@ -950,11 +951,12 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): | |||||||
|         return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id}) |         return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id}) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | ||||||
|     model = Membership |     model = Membership | ||||||
|     table_class = MembershipTable |     table_class = MembershipTable | ||||||
|     template_name = "member/club_members.html" |     template_name = "member/club_members.html" | ||||||
|     extra_context = {"title": _("Members of the club")} |     extra_context = {"title": _("Members of the club")} | ||||||
|  |     export_formats = ["csv"] | ||||||
|  |  | ||||||
|     def get_queryset(self, **kwargs): |     def get_queryset(self, **kwargs): | ||||||
|         qs = super().get_queryset().filter(club_id=self.kwargs["pk"]) |         qs = super().get_queryset().filter(club_id=self.kwargs["pk"]) | ||||||
| @@ -986,6 +988,14 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV | |||||||
|  |  | ||||||
|         return qs.distinct() |         return qs.distinct() | ||||||
|  |  | ||||||
|  |     def get_export_filename(self, export_format): | ||||||
|  |         return "members.csv" | ||||||
|  |  | ||||||
|  |     def get_export_content_type(self, export_format): | ||||||
|  |         if export_format == "csv": | ||||||
|  |             return "text/csv" | ||||||
|  |         return super().get_export_content_type(export_format) | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs): |     def get_context_data(self, **kwargs): | ||||||
|         context = super().get_context_data(**kwargs) |         context = super().get_context_data(**kwargs) | ||||||
|         club = Club.objects.filter( |         club = Club.objects.filter( | ||||||
|   | |||||||
| @@ -66,6 +66,8 @@ $(document).ready(function () { | |||||||
|       arr.push(last) |       arr.push(last) | ||||||
|  |  | ||||||
|       last.quantity = 1 |       last.quantity = 1 | ||||||
|  |        | ||||||
|  |        | ||||||
|  |  | ||||||
|       if (last.note.club) { |       if (last.note.club) { | ||||||
|         $('#last_name').val(last.note.name) |         $('#last_name').val(last.note.name) | ||||||
| @@ -111,7 +113,8 @@ $(document).ready(function () { | |||||||
|     dest.removeClass('d-none') |     dest.removeClass('d-none') | ||||||
|     $('#dest_note_list').removeClass('d-none') |     $('#dest_note_list').removeClass('d-none') | ||||||
|     $('#debit_type').addClass('d-none') |     $('#debit_type').addClass('d-none') | ||||||
|  |     $('#reason').val('') | ||||||
|  |    | ||||||
|     $('#source_note_label').text(select_emitters_label) |     $('#source_note_label').text(select_emitters_label) | ||||||
|     $('#dest_note_label').text(select_receveirs_label) |     $('#dest_note_label').text(select_receveirs_label) | ||||||
|  |  | ||||||
| @@ -134,6 +137,7 @@ $(document).ready(function () { | |||||||
|     dest.val('') |     dest.val('') | ||||||
|     dest.tooltip('hide') |     dest.tooltip('hide') | ||||||
|     $('#debit_type').addClass('d-none') |     $('#debit_type').addClass('d-none') | ||||||
|  |     $('#reason').val('Rechargement note') | ||||||
|  |  | ||||||
|     $('#source_note_label').text(transfer_type_label) |     $('#source_note_label').text(transfer_type_label) | ||||||
|     $('#dest_note_label').text(select_receveir_label) |     $('#dest_note_label').text(select_receveir_label) | ||||||
| @@ -162,6 +166,7 @@ $(document).ready(function () { | |||||||
|     dest.addClass('d-none') |     dest.addClass('d-none') | ||||||
|     dest.tooltip('hide') |     dest.tooltip('hide') | ||||||
|     $('#debit_type').removeClass('d-none') |     $('#debit_type').removeClass('d-none') | ||||||
|  |     $('#reason').val('') | ||||||
|  |  | ||||||
|     $('#source_note_label').text(select_emitter_label) |     $('#source_note_label').text(select_emitter_label) | ||||||
|     $('#dest_note_label').text(transfer_type_label) |     $('#dest_note_label').text(transfer_type_label) | ||||||
|   | |||||||
| @@ -4430,6 +4430,22 @@ | |||||||
|             "description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée" |             "description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée" | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |         "model": "permission.permission", | ||||||
|  |         "pk": 298, | ||||||
|  |         "fields": { | ||||||
|  |             "model": [ | ||||||
|  |                 "wei", | ||||||
|  |                 "bus" | ||||||
|  |             ], | ||||||
|  |             "query": "{\"pk\": [\"membership\", \"weimembership\", \"bus\", \"pk\"], \"wei__date_end__gte\": [\"today\"]}", | ||||||
|  |             "type": "change", | ||||||
|  |             "mask": 2, | ||||||
|  |             "field": "information_json", | ||||||
|  |             "permanent": false, | ||||||
|  |             "description": "Modifier les informations du bus" | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|         "model": "permission.permission", |         "model": "permission.permission", | ||||||
|         "pk": 311, |         "pk": 311, | ||||||
| @@ -4686,6 +4702,22 @@ | |||||||
|             "description": "Supprimer un succès" |             "description": "Supprimer un succès" | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |         "model": "permission.permission", | ||||||
|  |         "pk": 330, | ||||||
|  |         "fields": { | ||||||
|  |             "model": [ | ||||||
|  |                 "auth", | ||||||
|  |                 "user" | ||||||
|  |             ], | ||||||
|  |             "query": "{\"memberships__club\": [\"club\"]}", | ||||||
|  |             "type": "view", | ||||||
|  |             "mask": 2, | ||||||
|  |             "field": "email", | ||||||
|  |             "permanent": false, | ||||||
|  |             "description": "Voir l'adresse mail des membres de son club" | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|         "model": "permission.role", |         "model": "permission.role", | ||||||
|         "pk": 1, |         "pk": 1, | ||||||
| @@ -4833,7 +4865,11 @@ | |||||||
|                 221, |                 221, | ||||||
|                 247, |                 247, | ||||||
|                 258, |                 258, | ||||||
|                 259 |                 259, | ||||||
|  |                 260, | ||||||
|  |                 263, | ||||||
|  |                 265, | ||||||
|  |                 330 | ||||||
|             ] |             ] | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
| @@ -4845,7 +4881,6 @@ | |||||||
|             "name": "Pr\u00e9sident\u22c5e de club", |             "name": "Pr\u00e9sident\u22c5e de club", | ||||||
|             "permissions": [ |             "permissions": [ | ||||||
|                 62, |                 62, | ||||||
|                 135, |  | ||||||
|                 142 |                 142 | ||||||
|             ] |             ] | ||||||
|         } |         } | ||||||
| @@ -5122,7 +5157,8 @@ | |||||||
|                 289, |                 289, | ||||||
|                 290, |                 290, | ||||||
|                 291, |                 291, | ||||||
|                 293 |                 293, | ||||||
|  |                 298 | ||||||
|             ] |             ] | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
| @@ -5182,6 +5218,7 @@ | |||||||
|             "permissions": [ |             "permissions": [ | ||||||
|                 37, |                 37, | ||||||
|                 41, |                 41, | ||||||
|  |                 42, | ||||||
|                 53, |                 53, | ||||||
|                 54, |                 54, | ||||||
|                 55, |                 55, | ||||||
| @@ -5233,7 +5270,9 @@ | |||||||
|                 168, |                 168, | ||||||
|                 176, |                 176, | ||||||
|                 177, |                 177, | ||||||
|                 197 |                 197, | ||||||
|  |                 311, | ||||||
|  |                 319 | ||||||
|             ] |             ] | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
| @@ -5313,7 +5352,8 @@ | |||||||
|                 289, |                 289, | ||||||
|                 290, |                 290, | ||||||
|                 291, |                 291, | ||||||
|                 293 |                 293, | ||||||
|  |                 298 | ||||||
|             ] |             ] | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
|   | |||||||
| @@ -74,6 +74,7 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView): | |||||||
|  |  | ||||||
|         # For each product, we save it |         # For each product, we save it | ||||||
|         formset = ProductFormSet(self.request.POST, instance=form.instance) |         formset = ProductFormSet(self.request.POST, instance=form.instance) | ||||||
|  |         print(formset) | ||||||
|         if formset.is_valid(): |         if formset.is_valid(): | ||||||
|             for f in formset: |             for f in formset: | ||||||
|                 # We don't save the product if the designation is not entered, ie. if the line is empty |                 # We don't save the product if the designation is not entered, ie. if the line is empty | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ from ...models import WEIMembership, Bus | |||||||
|  |  | ||||||
| WORDS = { | WORDS = { | ||||||
|     'list': [ |     'list': [ | ||||||
|         'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nert et geek', 'Jeux de rôles et danse rock', |         'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nerd et geek', 'Jeux de rôles et danse rock', | ||||||
|         'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires', |         'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires', | ||||||
|         'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif', |         'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif', | ||||||
|         'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare', |         'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare', | ||||||
| @@ -57,7 +57,7 @@ WORDS = { | |||||||
|                 42: "Un burgouzz de valouzz", |                 42: "Un burgouzz de valouzz", | ||||||
|                 47: "Un ocarina (pour me téléporter hors de ce bourbier)", |                 47: "Un ocarina (pour me téléporter hors de ce bourbier)", | ||||||
|                 48: "Des paillettes, un micro de karaoké et une enceinte bluetooth", |                 48: "Des paillettes, un micro de karaoké et une enceinte bluetooth", | ||||||
|                 45: "", |                 45: "Un kebab", | ||||||
|                 44: "Une 86 et un caisson pour taper du pied", |                 44: "Une 86 et un caisson pour taper du pied", | ||||||
|                 46: "Une épée, un ballon et une tireuse", |                 46: "Une épée, un ballon et une tireuse", | ||||||
|                 43: "Des lunettes de soleil", |                 43: "Des lunettes de soleil", | ||||||
| @@ -176,7 +176,33 @@ WORDS = { | |||||||
|                 49: "Soirée raclette !" |                 49: "Soirée raclette !" | ||||||
|             } |             } | ||||||
|         ] |         ] | ||||||
|     } |     }, | ||||||
|  |     'stats': [ | ||||||
|  |         { | ||||||
|  |             "question": """Le WEI est structuré par bus, et au sein de chaque bus, par équipes. | ||||||
|  |                          Pour toi, être dans une équipe où tout le monde reste sobre (primo-entrants comme encadrants) c'est :""", | ||||||
|  |             "answers": [ | ||||||
|  |                 (1, "Inenvisageable"), | ||||||
|  |                 (2, "À contre cœur"), | ||||||
|  |                 (3, "Pourquoi pas"), | ||||||
|  |                 (4, "Souhaitable"), | ||||||
|  |                 (5, "Nécessaire"), | ||||||
|  |             ], | ||||||
|  |             "help_text": "(De toute façon aucun alcool n'est consommé pendant les trajets du bus, ni aller, ni retour.)", | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "question": "Faire partie d'un bus qui n'apporte pas de boisson alcoolisée pour ses membres, pour toi c'est :", | ||||||
|  |             "answers": [ | ||||||
|  |                 (1, "Inenvisageable"), | ||||||
|  |                 (2, "À contre cœur"), | ||||||
|  |                 (3, "Pourquoi pas"), | ||||||
|  |                 (4, "Souhaitable"), | ||||||
|  |                 (5, "Nécessaire"), | ||||||
|  |             ], | ||||||
|  |             "help_text": """(Tout les bus apportent de l'alcool cette année, cette question sert à l'organisation pour l'année prochaine. | ||||||
|  |                          De plus il y aura de toute façon de l'alcool commun au WEI et aucun alcool n'est consommé pendant les trajets en bus.)""", | ||||||
|  |         }, | ||||||
|  |     ] | ||||||
| } | } | ||||||
|  |  | ||||||
| IMAGES = { | IMAGES = { | ||||||
| @@ -235,7 +261,7 @@ class WEISurveyForm2025(forms.Form): | |||||||
|             all_preferred_words = WORDS['list'] |             all_preferred_words = WORDS['list'] | ||||||
|             rng.shuffle(all_preferred_words) |             rng.shuffle(all_preferred_words) | ||||||
|             self.fields["words"].choices = [(w, w) for w in all_preferred_words] |             self.fields["words"].choices = [(w, w) for w in all_preferred_words] | ||||||
|         else: |         elif information.step <= len(WORDS['questions']): | ||||||
|             questions = list(WORDS['questions'].items()) |             questions = list(WORDS['questions'].items()) | ||||||
|             idx = information.step - 1 |             idx = information.step - 1 | ||||||
|             if idx < len(questions): |             if idx < len(questions): | ||||||
| @@ -251,6 +277,15 @@ class WEISurveyForm2025(forms.Form): | |||||||
|                     widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})), |                     widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})), | ||||||
|                     required=True, |                     required=True, | ||||||
|                 ) |                 ) | ||||||
|  |         elif information.step == len(WORDS['questions']) + 1: | ||||||
|  |             for i, v in enumerate(WORDS['stats']): | ||||||
|  |                 self.fields[f'stat_{i}'] = forms.ChoiceField( | ||||||
|  |                     label=v['question'], | ||||||
|  |                     choices=v['answers'], | ||||||
|  |                     widget=forms.RadioSelect(), | ||||||
|  |                     required=False, | ||||||
|  |                     help_text=_(v.get('help_text', '')) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|     def clean_words(self): |     def clean_words(self): | ||||||
|         data = self.cleaned_data['words'] |         data = self.cleaned_data['words'] | ||||||
| @@ -377,7 +412,7 @@ class WEISurvey2025(WEISurvey): | |||||||
|                 setattr(self.information, "word" + str(i), word) |                 setattr(self.information, "word" + str(i), word) | ||||||
|             self.information.step += 1 |             self.information.step += 1 | ||||||
|             self.save() |             self.save() | ||||||
|         else: |         elif 1 <= self.information.step <= len(WORDS['questions']): | ||||||
|             questions = list(WORDS['questions'].keys()) |             questions = list(WORDS['questions'].keys()) | ||||||
|             idx = self.information.step - 1 |             idx = self.information.step - 1 | ||||||
|             if idx < len(questions): |             if idx < len(questions): | ||||||
| @@ -385,6 +420,13 @@ class WEISurvey2025(WEISurvey): | |||||||
|                 setattr(self.information, q, form.cleaned_data[q]) |                 setattr(self.information, q, form.cleaned_data[q]) | ||||||
|                 self.information.step += 1 |                 self.information.step += 1 | ||||||
|                 self.save() |                 self.save() | ||||||
|  |         else: | ||||||
|  |             for i, __ in enumerate(WORDS['stats']): | ||||||
|  |                 ans = form.cleaned_data.get(f'stat_{i}') | ||||||
|  |                 if ans is not None: | ||||||
|  |                     setattr(self.information, f'stat_{i}', ans) | ||||||
|  |             self.information.step += 1 | ||||||
|  |             self.save() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def get_algorithm_class(cls): |     def get_algorithm_class(cls): | ||||||
| @@ -394,7 +436,7 @@ class WEISurvey2025(WEISurvey): | |||||||
|         """ |         """ | ||||||
|         The survey is complete once the bus is chosen. |         The survey is complete once the bus is chosen. | ||||||
|         """ |         """ | ||||||
|         return self.information.step > len(WORDS['questions']) |         return self.information.step > len(WORDS['questions']) + 1 | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     @lru_cache() |     @lru_cache() | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 34 KiB | 
| @@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|         {{ title }} |         {{ title }} | ||||||
|     </h3> |     </h3> | ||||||
|     <div class="card-body"> |     <div class="card-body"> | ||||||
|         <form method="post"> |         <form id="registration-form" method="post"> | ||||||
|             {% csrf_token %} |             {% csrf_token %} | ||||||
|             {{ form|crispy }} |             {{ form|crispy }} | ||||||
|             {{ membership_form|crispy }} |             {{ membership_form|crispy }} | ||||||
| @@ -22,6 +22,46 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block extrajavascript %} | {% block extrajavascript %} | ||||||
|  | <!-- intl-tel-input CSS/JS --> | ||||||
|  | <script> | ||||||
|  | (() => { | ||||||
|  |     const input = document.querySelector("input[name='emergency_contact_phone']"); | ||||||
|  |     const form = document.querySelector("#registration-form"); | ||||||
|  |  | ||||||
|  |     if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const iti = window.intlTelInput(input, { | ||||||
|  |         initialCountry: "auto", | ||||||
|  |         nationalMode: false, | ||||||
|  |         autoPlaceholder: "off", | ||||||
|  |         geoIpLookup: callback => { | ||||||
|  |             fetch("https://ipapi.co/json") | ||||||
|  |                 .then(res => res.json()) | ||||||
|  |                 .then(data => callback(data.country_code)) | ||||||
|  |                 .catch(() => callback("fr")); | ||||||
|  |         }, | ||||||
|  |         loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     form.addEventListener("submit", function(e){ | ||||||
|  |         if (!input.value.trim()) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const number = iti.getNumber(intlTelInput.utils.numberFormat.E164); | ||||||
|  |         if (number) { | ||||||
|  |             input.value = number; | ||||||
|  |             form.submit(); | ||||||
|  |         } else { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             input.focus(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | })(); | ||||||
|  | </script> | ||||||
|  |  | ||||||
| {% if not object.membership %} | {% if not object.membership %} | ||||||
| <script> | <script> | ||||||
|     $(document).ready(function () { |     $(document).ready(function () { | ||||||
|   | |||||||
| @@ -53,9 +53,11 @@ class TestWEIAlgorithm(TestCase): | |||||||
|                 birth_date='2000-01-01', |                 birth_date='2000-01-01', | ||||||
|             ) |             ) | ||||||
|             information = WEISurveyInformation2025(registration) |             information = WEISurveyInformation2025(registration) | ||||||
|             for j in range(1, 21): |             for j in range(1, 1 + NB_WORDS): | ||||||
|                 setattr(information, f'word{j}', random.choice(WORDS['list'])) |                 setattr(information, f'word{j}', random.choice(WORDS['list'])) | ||||||
|             information.step = 20 |             for q in WORDS['questions']: | ||||||
|  |                 setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys()))) | ||||||
|  |             information.step = len(WORDS['questions']) + 2 | ||||||
|             information.save(registration) |             information.save(registration) | ||||||
|             registration.save() |             registration.save() | ||||||
|  |  | ||||||
| @@ -87,7 +89,7 @@ class TestWEIAlgorithm(TestCase): | |||||||
|                 setattr(information, f'word{j}', random.choice(WORDS['list'])) |                 setattr(information, f'word{j}', random.choice(WORDS['list'])) | ||||||
|             for q in WORDS['questions']: |             for q in WORDS['questions']: | ||||||
|                 setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys()))) |                 setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys()))) | ||||||
|             information.step = len(WORDS['questions']) + 1 |             information.step = len(WORDS['questions']) + 2 | ||||||
|             information.save(registration) |             information.save(registration) | ||||||
|             registration.save() |             registration.save() | ||||||
|             survey = WEISurvey2025(registration) |             survey = WEISurvey2025(registration) | ||||||
|   | |||||||
| @@ -770,7 +770,7 @@ msgstr "Créer une famille ou un défi" | |||||||
|  |  | ||||||
| #: apps/family/templates/family/manage.html:96 | #: apps/family/templates/family/manage.html:96 | ||||||
| msgid "Add a family" | msgid "Add a family" | ||||||
| msgstr "Ajouter une famille" | msgstr "Fonder une famille" | ||||||
|  |  | ||||||
| #: apps/family/templates/family/manage.html:101 | #: apps/family/templates/family/manage.html:101 | ||||||
| msgid "Add a challenge" | msgid "Add a challenge" | ||||||
|   | |||||||
| @@ -306,8 +306,8 @@ PIC_WIDTH = 200 | |||||||
| PIC_RATIO = 1 | PIC_RATIO = 1 | ||||||
|  |  | ||||||
| # Custom phone number format | # Custom phone number format | ||||||
| PHONENUMBER_DB_FORMAT = 'NATIONAL' | PHONENUMBER_DB_FORMAT = 'E164' | ||||||
| PHONENUMBER_DEFAULT_REGION = 'FR' | PHONENUMBER_DEFAULT_REGION = None | ||||||
|  |  | ||||||
| # We add custom information to CAS, in order to give a normalized name to other services | # We add custom information to CAS, in order to give a normalized name to other services | ||||||
| CAS_AUTH_CLASS = 'member.auth.CustomAuthUser' | CAS_AUTH_CLASS = 'member.auth.CustomAuthUser' | ||||||
|   | |||||||
| @@ -29,6 +29,8 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|     <link rel="stylesheet" href="{% static "bootstrap4/css/bootstrap.min.css" %}"> |     <link rel="stylesheet" href="{% static "bootstrap4/css/bootstrap.min.css" %}"> | ||||||
|     <link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}"> |     <link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}"> | ||||||
|     <link rel="stylesheet" href="{% static "css/custom.css" %}"> |     <link rel="stylesheet" href="{% static "css/custom.css" %}"> | ||||||
|  |      | ||||||
|  |     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/css/intlTelInput.css"> | ||||||
|  |  | ||||||
|     {# JQuery, Bootstrap and Turbolinks JavaScript #} |     {# JQuery, Bootstrap and Turbolinks JavaScript #} | ||||||
|     <script src="{% static "jquery/jquery.min.js" %}"></script> |     <script src="{% static "jquery/jquery.min.js" %}"></script> | ||||||
| @@ -41,6 +43,8 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|     {# Translation in javascript files #} |     {# Translation in javascript files #} | ||||||
|     <script src="{% static "js/jsi18n/"|add:LANGUAGE_CODE|add:".js" %}"></script> |     <script src="{% static "js/jsi18n/"|add:LANGUAGE_CODE|add:".js" %}"></script> | ||||||
|  |  | ||||||
|  |     <script src="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/intlTelInput.min.js"></script> | ||||||
|  |      | ||||||
|     {# If extra ressources are needed for a form, load here #} |     {# If extra ressources are needed for a form, load here #} | ||||||
|     {% if form.media %} |     {% if form.media %} | ||||||
|         {{ form.media }} |         {{ form.media }} | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|             {% endblocktrans %} |             {% endblocktrans %} | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <form method="post"> |         <form method="post" id="profile_form"> | ||||||
|             {% csrf_token %} |             {% csrf_token %} | ||||||
|             {{ form|crispy }} |             {{ form|crispy }} | ||||||
|             {{ profile_form|crispy }} |             {{ profile_form|crispy }} | ||||||
| @@ -31,3 +31,45 @@ SPDX-License-Identifier: GPL-3.0-or-later | |||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block extrajavascript %} | ||||||
|  | <!-- intl-tel-input CSS/JS --> | ||||||
|  | <script> | ||||||
|  | (() => { | ||||||
|  |     const input = document.querySelector("input[name='phone_number']"); | ||||||
|  |     const form = document.querySelector("#profile_form"); | ||||||
|  |  | ||||||
|  |     if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const iti = window.intlTelInput(input, { | ||||||
|  |         initialCountry: "auto", | ||||||
|  |         nationalMode: false, | ||||||
|  |         autoPlaceholder: "off", | ||||||
|  |         geoIpLookup: callback => { | ||||||
|  |             fetch("https://ipapi.co/json") | ||||||
|  |                 .then(res => res.json()) | ||||||
|  |                 .then(data => callback(data.country_code)) | ||||||
|  |                 .catch(() => callback("fr")); | ||||||
|  |         }, | ||||||
|  |         loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     form.addEventListener("submit", function(e){ | ||||||
|  |         if (!input.value.trim()) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const number = iti.getNumber(intlTelInput.utils.numberFormat.E164); | ||||||
|  |         if (number) { | ||||||
|  |             input.value = number; | ||||||
|  |             form.submit(); | ||||||
|  |         } else { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             input.focus(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | })(); | ||||||
|  | </script> | ||||||
|  | {% endblock %} | ||||||
| @@ -18,4 +18,5 @@ django-rest-polymorphic~=0.1.10 | |||||||
| django-tables2~=2.7.5 | django-tables2~=2.7.5 | ||||||
| python-memcached~=1.62 | python-memcached~=1.62 | ||||||
| phonenumbers~=9.0.8 | phonenumbers~=9.0.8 | ||||||
|  | tablib~=3.8.0 | ||||||
| Pillow>=11.3.0 | Pillow>=11.3.0 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user