1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-10-31 15:50:03 +01:00

Compare commits

...

20 Commits

Author SHA1 Message Date
Ehouarn
6bf21b103f Alpha version (without tests) 2025-10-30 23:54:23 +01:00
ehouarn
d4cb464169 Merge branch 'small_features' into 'main'
Export activity guests

See merge request bde/nk20!353
2025-09-28 21:34:21 +02:00
Ehouarn
27a1f36183 Export club members 2025-09-28 21:15:01 +02:00
Ehouarn
83c8b9a3d0 Export activity guests 2025-09-28 21:12:48 +02:00
ehouarn
cb3b34f874 Merge branch 'small_features' into 'main'
Small features

See merge request bde/nk20!352
2025-09-27 13:39:34 +02:00
Ehouarn
0962a3735e Better Food search 2025-09-27 13:19:48 +02:00
Ehouarn
9907cfbd86 Autocomplete Credit reason with 'Rechargement note' 2025-09-27 01:17:33 +02:00
Ehouarn
ad90887691 Search activities 2025-09-26 22:58:30 +02:00
Ehouarn
47d2476b51 Allow to view activity entries on Activity tab 2025-09-25 00:08:56 +02:00
Ehouarn
5d8720cf46 Phone input without permission fixed 2025-09-24 22:22:23 +02:00
Ehouarn
8700144dea Permissions 2025-09-24 21:48:56 +02:00
ehouarn
d17ab26f2f Merge branch 'phone_input' into 'main'
Phone input

See merge request bde/nk20!351
2025-09-03 18:40:26 +02:00
ehouarn
297f289d7e Merge branch 'wei' into 'main'
New informative questions

See merge request bde/nk20!350
2025-08-31 22:25:57 +02:00
Ehouarn
034ad9a4ce tests 2025-08-31 22:04:45 +02:00
Ehouarn
897d37f74d New informative questions 2025-08-31 21:45:09 +02:00
sable
42fb0aa2d6 Merge branch 'translations' into 'main'
minor translate

See merge request bde/nk20!349
2025-08-31 13:36:58 +02:00
sable
4bc43ec3cb minor translate 2025-08-31 13:19:27 +02:00
ehouarn
00737da69f Merge branch 'family' into 'main'
minor fixe

See merge request bde/nk20!348
2025-08-31 13:02:29 +02:00
Ehouarn
0934b8fa34 Patch 2025-08-30 16:15:55 +02:00
Ehouarn
7633c9ab4b Better phone input (no invalid number) 2025-08-29 18:36:18 +02:00
45 changed files with 1697 additions and 49 deletions

View File

@@ -48,5 +48,15 @@
"can_invite": true,
"guest_entry_fee": 0
}
},
{
"model": "activity.activitytype",
"pk": 8,
"fields": {
"name": "Perm bouffe",
"manage_entries": false,
"can_invite": false,
"guest_entry_fee": 0
}
}
]
]

View File

@@ -37,6 +37,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div id="guests_table">
{% render_table guests %}
</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>
{% endif %}
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "base_search.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
@@ -44,6 +44,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h3 class="card-header text-center">
{% trans "All activities" %}
</h3>
{% render_table table %}
{% render_table all %}
</div>
{{ block.super }}
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms pretty_money %}
{% load i18n perms pretty_money dict_get %}
{% url 'activity:activity_detail' activity.pk as activity_detail_url %}
<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>
<dd class="col-xl-6">{{ activity.open|yesno }}</dd>
</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 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>
{% 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 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>

View File

View 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)

View File

@@ -67,32 +67,65 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
tables = [
lambda data: ActivityTable(data, prefix="all-"),
lambda data: ActivityTable(data, prefix="upcoming-"),
lambda data: ActivityTable(data, prefix="search-"),
]
extra_context = {"title": _("Activities")}
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):
# 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 [
self.get_queryset().order_by("-date_start"),
Activity.objects.filter(date_end__gt=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
.distinct()
.order_by("date_start")
.order_by("date_start"),
search_table,
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
tables = context["tables"]
for name, table in zip(["table", "upcoming"], tables):
for name, table in zip(["all", "upcoming", "table"], tables):
context[name] = table
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
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
@@ -103,12 +136,19 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
model = Activity
context_object_name = "activity"
extra_context = {"title": _("Activity detail")}
export_formats = ["csv"]
tables = [
lambda data: GuestTable(data, prefix="guests-"),
lambda data: OpenerTable(data, prefix="opener-"),
GuestTable,
OpenerTable,
]
def get_tables(self):
tables = super().get_tables()
tables[0].prefix = "guests"
tables[1].prefix = "opener"
return tables
def get_tables_data(self):
return [
Guest.objects.filter(activity=self.object)
@@ -117,6 +157,51 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
.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):
context = super().get_context_data()
@@ -137,6 +222,14 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
"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

View File

@@ -3,7 +3,7 @@
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):
@@ -54,3 +54,43 @@ class QRCodeSerializer(serializers.ModelSerializer):
class Meta:
model = QRCode
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__'

View File

@@ -1,7 +1,8 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# 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):
@@ -13,3 +14,7 @@ def register_food_urls(router, path):
router.register(path + '/basicfood', BasicFoodViewSet)
router.register(path + '/transformedfood', TransformedFoodViewSet)
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)

View File

@@ -3,10 +3,12 @@
from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from django.utils import timezone
from rest_framework.filters import SearchFilter
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \
DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
class AllergenViewSet(ReadProtectedModelViewSet):
@@ -72,3 +74,61 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_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', ]

View File

@@ -4,15 +4,16 @@
from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from crispy_forms.helper import FormHelper
from django import forms
from django.forms.widgets import NumberInput
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note_kfet.inputs import Autocomplete
from note_kfet.inputs import Autocomplete, AmountInput
from note_kfet.middlewares import get_current_request
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):
@@ -185,3 +186,60 @@ ManageIngredientsFormSet = forms.formset_factory(
ManageIngredientsForm,
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")

View File

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

View File

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

View File

@@ -4,10 +4,14 @@
from datetime import timedelta
from django.db import models, transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
from member.models import Club
from activity.models import Activity
from note.models import Transaction
class Allergen(models.Model):
@@ -284,3 +288,199 @@ class QRCode(models.Model):
def __str__(self):
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")

View 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);
});
}

View File

@@ -2,20 +2,120 @@
# SPDX-License-Identifier: GPL-3.0-or-later
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):
"""
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:
model = Food
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'owner', 'allergens', 'expiry_date')
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk),
'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',
}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -34,6 +34,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
</div>
<div class="card-body">
<div class="form-check">
<label for="stock_only" class="form-check-label">
<input id="stock_only" name="stock_only" type="checkbox" class="checkboxinput form-check-input" checked>
{% trans "Filter with only food in stock" %}
</label>
</div>
<input id="searchbar" type="text" class="form-control"
placeholder="{% trans "Search by attribute such as name..." %}">
</div>
@@ -58,13 +64,19 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h3 class="card-header text-center">
{% trans "Meal served" %}
</h3>
{% if can_add_meal %}
<div class="card-footer">
{% if can_add_meal %}
<a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}">
{% trans "New meal" %}
</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>
{% endif %}
{% if served.data %}
{% render_table served %}
{% else %}
@@ -114,7 +126,26 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
</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>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('goButton').addEventListener('click', function(event) {

View 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 %}

View 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 %}

View 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%}

View 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%}

View 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 %}

View File

@@ -19,4 +19,14 @@ urlpatterns = [
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('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'),
]

View File

@@ -4,25 +4,30 @@
from datetime import timedelta
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.models import Q
from django.http import HttpResponseRedirect, Http404
from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView
from django.views.generic.base import RedirectView
from django.views.generic.edit import DeleteView
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
from activity.models import Activity
from permission.backends import PermissionBackend
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, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms
from .tables import FoodTable
BasicFoodUpdateForms, TransformedFoodUpdateForms, \
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm
from .tables import FoodTable, DishTable, OrderTable
from .utils import pretty_duration
@@ -65,9 +70,13 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else ''
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
| Q(**{f'owner__name{suffix}': prefix + pattern}))
| Q(**{f'owner__name{suffix}': prefix + pattern})
| Q(**{f'owner__note__alias__name{suffix}': prefix + pattern}))
else:
qs = qs.none()
if "stock" not in self.request.GET or not self.request.GET["stock"] == '1':
qs = qs.filter(end_of_life='')
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table open
open_table = self.get_queryset().order_by('expiry_date').filter(
@@ -95,6 +104,7 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
owner=club, end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view')
))
return [search_table, open_table, served_table] + club_table
def get_context_data(self, **kwargs):
@@ -107,6 +117,9 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
context['club_tables'] = tables[3:]
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
@@ -218,7 +231,7 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
copy = self.request.GET.get('copy', None)
if copy is not None:
food = BasicFood.objects.get(pk=copy)
print(context['form'].fields)
for field in context['form'].fields:
if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all()
@@ -521,3 +534,270 @@ class QRCodeRedirectView(RedirectView):
if slug:
return reverse_lazy('food:qrcode_create', kwargs={'slug': slug})
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"]})

View File

@@ -10,6 +10,7 @@ from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
from django.db import transaction
from django.forms import CheckboxSelectMultiple
from phonenumber_field.formfields import PhoneNumberField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias
@@ -45,6 +46,11 @@ class ProfileForm(forms.ModelForm):
A form for the extras field provided by the :model:`member.Profile` model.
"""
# Remove widget=forms.HiddenInput() if you want to use report frequency.
phone_number = PhoneNumberField(
widget=forms.TextInput(attrs={"type": "tel", "class": "form-control"}),
required=False
)
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
@@ -72,7 +78,12 @@ class ProfileForm(forms.ModelForm):
if not self.instance.section or (("department" in self.changed_data
or "promotion" in self.changed_data) and "section" not in self.changed_data):
self.instance.section = self.instance.section_generated
return super().save(commit)
instance = super().save(commit=False)
if instance.phone_number:
instance.phone_number = instance.phone_number.as_e164
if commit:
instance.save()
return instance
class Meta:
model = Profile

View File

@@ -92,6 +92,20 @@ class MembershipTable(tables.Table):
}
)
user_email = tables.Column(
verbose_name="Email",
accessor="user.email",
orderable=False,
visible=False,
)
user_full_name = tables.Column(
verbose_name=_("Full name"),
accessor="user.get_full_name",
orderable=False,
visible=False,
)
def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail.
s = value.username
@@ -149,6 +163,16 @@ class MembershipTable(tables.Table):
+ "'>" + s + "</a>")
return s
def value_user(self, record):
return record.user.username if record.user else ""
def value_club(self, record):
return record.club.name if record.club else ""
def value_roles(self, record):
roles = record.roles.all()
return ", ".join(str(role) for role in roles)
class Meta:
attrs = {
'class': 'table table-condensed table-striped',

View File

@@ -36,7 +36,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "There is no membership found with this pattern." %}
</div>
{% 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>
{% endblock %}

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }}
</h3>
<div class="card-body">
<form method="post">
<form method="post" id="profile-form">
{% csrf_token %}
{{ form | crispy }}
{{ profile_form | crispy }}
@@ -20,4 +20,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
</form>
</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 %}

View File

@@ -17,6 +17,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin
from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
from django_tables2.export.views import ExportMixin
from rest_framework.authtoken.models import Token
from api.viewsets import is_regex
from note.models import Alias, NoteClub, NoteUser, Trust
@@ -950,11 +951,12 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id})
class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
model = Membership
table_class = MembershipTable
template_name = "member/club_members.html"
extra_context = {"title": _("Members of the club")}
export_formats = ["csv"]
def get_queryset(self, **kwargs):
qs = super().get_queryset().filter(club_id=self.kwargs["pk"])
@@ -986,6 +988,14 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
return qs.distinct()
def get_export_filename(self, export_format):
return "members.csv"
def get_export_content_type(self, export_format):
if export_format == "csv":
return "text/csv"
return super().get_export_content_type(export_format)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = Club.objects.filter(

View File

@@ -66,6 +66,8 @@ $(document).ready(function () {
arr.push(last)
last.quantity = 1
if (last.note.club) {
$('#last_name').val(last.note.name)
@@ -111,7 +113,8 @@ $(document).ready(function () {
dest.removeClass('d-none')
$('#dest_note_list').removeClass('d-none')
$('#debit_type').addClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitters_label)
$('#dest_note_label').text(select_receveirs_label)
@@ -134,6 +137,7 @@ $(document).ready(function () {
dest.val('')
dest.tooltip('hide')
$('#debit_type').addClass('d-none')
$('#reason').val('Rechargement note')
$('#source_note_label').text(transfer_type_label)
$('#dest_note_label').text(select_receveir_label)
@@ -162,6 +166,7 @@ $(document).ready(function () {
dest.addClass('d-none')
dest.tooltip('hide')
$('#debit_type').removeClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitter_label)
$('#dest_note_label').text(transfer_type_label)

View File

@@ -4430,6 +4430,22 @@
"description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée"
}
},
{
"model": "permission.permission",
"pk": 298,
"fields": {
"model": [
"wei",
"bus"
],
"query": "{\"pk\": [\"membership\", \"weimembership\", \"bus\", \"pk\"], \"wei__date_end__gte\": [\"today\"]}",
"type": "change",
"mask": 2,
"field": "information_json",
"permanent": false,
"description": "Modifier les informations du bus"
}
},
{
"model": "permission.permission",
"pk": 311,
@@ -4686,6 +4702,22 @@
"description": "Supprimer un succès"
}
},
{
"model": "permission.permission",
"pk": 330,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"memberships__club\": [\"club\"]}",
"type": "view",
"mask": 2,
"field": "email",
"permanent": false,
"description": "Voir l'adresse mail des membres de son club"
}
},
{
"model": "permission.role",
"pk": 1,
@@ -4833,7 +4865,11 @@
221,
247,
258,
259
259,
260,
263,
265,
330
]
}
},
@@ -4845,7 +4881,6 @@
"name": "Pr\u00e9sident\u22c5e de club",
"permissions": [
62,
135,
142
]
}
@@ -5122,7 +5157,8 @@
289,
290,
291,
293
293,
298
]
}
},
@@ -5182,6 +5218,7 @@
"permissions": [
37,
41,
42,
53,
54,
55,
@@ -5233,7 +5270,9 @@
168,
176,
177,
197
197,
311,
319
]
}
},
@@ -5313,7 +5352,8 @@
289,
290,
291,
293
293,
298
]
}
},

View File

@@ -74,6 +74,7 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
# For each product, we save it
formset = ProductFormSet(self.request.POST, instance=form.instance)
print(formset)
if formset.is_valid():
for f in formset:
# We don't save the product if the designation is not entered, ie. if the line is empty

View File

@@ -17,7 +17,7 @@ from ...models import WEIMembership, Bus
WORDS = {
'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',
'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif',
'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare',
@@ -57,7 +57,7 @@ WORDS = {
42: "Un burgouzz de valouzz",
47: "Un ocarina (pour me téléporter hors de ce bourbier)",
48: "Des paillettes, un micro de karaoké et une enceinte bluetooth",
45: "",
45: "Un kebab",
44: "Une 86 et un caisson pour taper du pied",
46: "Une épée, un ballon et une tireuse",
43: "Des lunettes de soleil",
@@ -176,7 +176,33 @@ WORDS = {
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 = {
@@ -235,7 +261,7 @@ class WEISurveyForm2025(forms.Form):
all_preferred_words = WORDS['list']
rng.shuffle(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())
idx = information.step - 1
if idx < len(questions):
@@ -251,6 +277,15 @@ class WEISurveyForm2025(forms.Form):
widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})),
required=True,
)
elif information.step == len(WORDS['questions']) + 1:
for i, v in enumerate(WORDS['stats']):
self.fields[f'stat_{i}'] = forms.ChoiceField(
label=v['question'],
choices=v['answers'],
widget=forms.RadioSelect(),
required=False,
help_text=_(v.get('help_text', ''))
)
def clean_words(self):
data = self.cleaned_data['words']
@@ -377,7 +412,7 @@ class WEISurvey2025(WEISurvey):
setattr(self.information, "word" + str(i), word)
self.information.step += 1
self.save()
else:
elif 1 <= self.information.step <= len(WORDS['questions']):
questions = list(WORDS['questions'].keys())
idx = self.information.step - 1
if idx < len(questions):
@@ -385,6 +420,13 @@ class WEISurvey2025(WEISurvey):
setattr(self.information, q, form.cleaned_data[q])
self.information.step += 1
self.save()
else:
for i, __ in enumerate(WORDS['stats']):
ans = form.cleaned_data.get(f'stat_{i}')
if ans is not None:
setattr(self.information, f'stat_{i}', ans)
self.information.step += 1
self.save()
@classmethod
def get_algorithm_class(cls):
@@ -394,7 +436,7 @@ class WEISurvey2025(WEISurvey):
"""
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
@lru_cache()

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }}
</h3>
<div class="card-body">
<form method="post">
<form id="registration-form" method="post">
{% csrf_token %}
{{ form|crispy }}
{{ membership_form|crispy }}
@@ -22,6 +22,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %}
{% 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 %}
<script>
$(document).ready(function () {

View File

@@ -53,9 +53,11 @@ class TestWEIAlgorithm(TestCase):
birth_date='2000-01-01',
)
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']))
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)
registration.save()
@@ -87,7 +89,7 @@ class TestWEIAlgorithm(TestCase):
setattr(information, f'word{j}', random.choice(WORDS['list']))
for q in WORDS['questions']:
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
information.step = len(WORDS['questions']) + 1
information.step = len(WORDS['questions']) + 2
information.save(registration)
registration.save()
survey = WEISurvey2025(registration)

View File

@@ -770,7 +770,7 @@ msgstr "Créer une famille ou un défi"
#: apps/family/templates/family/manage.html:96
msgid "Add a family"
msgstr "Ajouter une famille"
msgstr "Fonder une famille"
#: apps/family/templates/family/manage.html:101
msgid "Add a challenge"

View File

@@ -306,8 +306,8 @@ PIC_WIDTH = 200
PIC_RATIO = 1
# Custom phone number format
PHONENUMBER_DB_FORMAT = 'NATIONAL'
PHONENUMBER_DEFAULT_REGION = 'FR'
PHONENUMBER_DB_FORMAT = 'E164'
PHONENUMBER_DEFAULT_REGION = None
# We add custom information to CAS, in order to give a normalized name to other services
CAS_AUTH_CLASS = 'member.auth.CustomAuthUser'

View File

@@ -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 "font-awesome/css/font-awesome.min.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 #}
<script src="{% static "jquery/jquery.min.js" %}"></script>
@@ -41,6 +43,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{# Translation in javascript files #}
<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 form.media %}
{{ form.media }}

View File

@@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %}
</div>
<form method="post">
<form method="post" id="profile_form">
{% csrf_token %}
{{ form|crispy }}
{{ profile_form|crispy }}
@@ -31,3 +31,45 @@ SPDX-License-Identifier: GPL-3.0-or-later
</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 %}

View File

@@ -18,4 +18,5 @@ django-rest-polymorphic~=0.1.10
django-tables2~=2.7.5
python-memcached~=1.62
phonenumbers~=9.0.8
tablib~=3.8.0
Pillow>=11.3.0