mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-11-29 00:37:05 +01:00
Compare commits
25 Commits
efbc64a064
...
note_sheet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac9ed2353 | ||
|
|
0fad2a876d | ||
|
|
12b2e7869a | ||
|
|
0240ea5388 | ||
|
|
13171899c2 | ||
|
|
dacedbff20 | ||
|
|
a61a4667b9 | ||
|
|
9998189dbf | ||
|
|
08593700fc | ||
|
|
54d28b30e5 | ||
|
|
c09f133652 | ||
|
|
bfd50e3cd5 | ||
|
|
6a77cfd4dd | ||
|
|
68341a2a7e | ||
|
|
7af3c42a02 | ||
|
|
73b63186fd | ||
|
|
48b1ef9ec8 | ||
|
|
4f016fed38 | ||
|
|
6cffe94bae | ||
|
|
78372807f8 | ||
|
|
b9bf01f2e3 | ||
|
|
624f94823c | ||
|
|
30a598c0b7 | ||
|
|
6bf21b103f | ||
|
|
d2cc1b902d |
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -65,6 +65,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %}
|
||||
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
|
||||
{% endif %}
|
||||
{% if false %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
|
||||
{% if request.path_info == activity_detail_url %}
|
||||
{% if activity.valid and ".change__open"|has_perm:activity %}
|
||||
|
||||
@@ -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):
|
||||
@@ -21,9 +21,13 @@ class FoodSerializer(serializers.ModelSerializer):
|
||||
REST API Serializer for Food.
|
||||
The djangorestframework plugin will analyse the model `Food` and parse all fields in the API.
|
||||
"""
|
||||
# This fields is used for autocompleting food in ManageIngredientsView
|
||||
# TODO Find a better way to do it
|
||||
owner_name = serializers.CharField(source='owner.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = '__all__'
|
||||
fields = ['name', 'owner', 'allergens', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'owner_name']
|
||||
|
||||
|
||||
class BasicFoodSerializer(serializers.ModelSerializer):
|
||||
@@ -54,3 +58,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__'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,8 +5,9 @@ from api.viewsets import ReadProtectedModelViewSet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
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 +73,55 @@ 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', ]
|
||||
|
||||
|
||||
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,17 @@
|
||||
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.forms import CheckboxSelectMultiple
|
||||
from django.forms.widgets import NumberInput, TextInput
|
||||
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, Recipe
|
||||
|
||||
|
||||
class QRCodeForms(forms.ModelForm):
|
||||
@@ -54,7 +56,7 @@ class BasicFoodForms(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'traces', 'order',)
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
@@ -97,7 +99,7 @@ class BasicFoodUpdateForms(forms.ModelForm):
|
||||
"""
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens')
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens', 'traces')
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
@@ -133,7 +135,7 @@ class AddIngredientForms(forms.ModelForm):
|
||||
Form for add an ingredient
|
||||
"""
|
||||
fully_used = forms.BooleanField()
|
||||
fully_used.initial = True
|
||||
fully_used.initial = False
|
||||
fully_used.required = False
|
||||
fully_used.label = _("Fully used")
|
||||
|
||||
@@ -141,11 +143,14 @@ class AddIngredientForms(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
# TODO find a better way to get pk (be not url scheme dependant)
|
||||
pk = get_current_request().path.split('/')[-1]
|
||||
self.fields['ingredients'].queryset = self.fields['ingredients'].queryset.filter(
|
||||
qs = self.fields['ingredients'].queryset.filter(
|
||||
polymorphic_ctype__model="transformedfood",
|
||||
is_ready=False,
|
||||
end_of_life='',
|
||||
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")).exclude(pk=pk)
|
||||
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change"))
|
||||
if pk:
|
||||
qs = qs.exclude(pk=pk)
|
||||
self.fields['ingredients'].queryset = qs
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
@@ -157,7 +162,7 @@ class ManageIngredientsForm(forms.Form):
|
||||
Form to manage ingredient
|
||||
"""
|
||||
fully_used = forms.BooleanField()
|
||||
fully_used.initial = True
|
||||
fully_used.initial = False
|
||||
fully_used.required = True
|
||||
fully_used.label = _('Fully used')
|
||||
|
||||
@@ -166,7 +171,7 @@ class ManageIngredientsForm(forms.Form):
|
||||
model=Food,
|
||||
resetable=True,
|
||||
attrs={"api_url": "/api/food/food",
|
||||
"class": "autocomplete"},
|
||||
"class": "autocomplete manageingredients-autocomplete"},
|
||||
)
|
||||
name.label = _('Name')
|
||||
|
||||
@@ -180,8 +185,116 @@ class ManageIngredientsForm(forms.Form):
|
||||
)
|
||||
qrcode.label = _('QR code number')
|
||||
|
||||
add_all_same_name = forms.BooleanField(
|
||||
required=False,
|
||||
label=_("Add all identical food")
|
||||
)
|
||||
|
||||
|
||||
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=1,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class RecipeForm(forms.ModelForm):
|
||||
"""
|
||||
Form to create a recipe
|
||||
"""
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'creater',)
|
||||
widgets = {
|
||||
"creater": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class RecipeIngredientsForm(forms.Form):
|
||||
"""
|
||||
Form to add ingredients to a recipe
|
||||
"""
|
||||
name = forms.CharField()
|
||||
name.widget = TextInput()
|
||||
name.label = _("Name")
|
||||
|
||||
|
||||
RecipeIngredientsFormSet = forms.formset_factory(
|
||||
RecipeIngredientsForm,
|
||||
extra=1,
|
||||
)
|
||||
|
||||
|
||||
class UseRecipeForm(forms.Form):
|
||||
"""
|
||||
Form to add ingredients to a TransformedFood using a Recipe
|
||||
"""
|
||||
recipe = forms.ModelChoiceField(
|
||||
queryset=Recipe.objects,
|
||||
label=_('Recipe'),
|
||||
)
|
||||
|
||||
ingredients = forms.ModelMultipleChoiceField(
|
||||
queryset=Food.objects,
|
||||
label=_("Ingredients"),
|
||||
widget=CheckboxSelectMultiple(),
|
||||
)
|
||||
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
19
apps/food/migrations/0004_alter_foodtransaction_order.py
Normal file
19
apps/food/migrations/0004_alter_foodtransaction_order.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-31 17:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('food', '0003_dish_order_foodtransaction_supplement_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='foodtransaction',
|
||||
name='order',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='food.order', verbose_name='order'),
|
||||
),
|
||||
]
|
||||
18
apps/food/migrations/0005_food_traces.py
Normal file
18
apps/food/migrations/0005_food_traces.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-02 17:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('food', '0004_alter_foodtransaction_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='traces',
|
||||
field=models.ManyToManyField(blank=True, related_name='food_with_traces', to='food.allergen', verbose_name='traces'),
|
||||
),
|
||||
]
|
||||
29
apps/food/migrations/0006_recipe.py
Normal file
29
apps/food/migrations/0006_recipe.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-06 17:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('food', '0005_food_traces'),
|
||||
('member', '0015_alter_profile_promotion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Recipe',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('ingredients_json', models.TextField(blank=True, default='[]', help_text='Ingredients of the recipe, encoded in JSON', verbose_name='list of ingredients')),
|
||||
('creater', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='member.club', verbose_name='creater')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recipe',
|
||||
'verbose_name_plural': 'Recipes',
|
||||
'unique_together': {('name', 'creater')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,13 +1,18 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
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):
|
||||
@@ -49,6 +54,13 @@ class Food(PolymorphicModel):
|
||||
verbose_name=_('allergens'),
|
||||
)
|
||||
|
||||
traces = models.ManyToManyField(
|
||||
Allergen,
|
||||
blank=True,
|
||||
verbose_name=_('traces'),
|
||||
related_name='food_with_traces'
|
||||
)
|
||||
|
||||
expiry_date = models.DateTimeField(
|
||||
verbose_name=_('expiry date'),
|
||||
null=False,
|
||||
@@ -87,6 +99,19 @@ class Food(PolymorphicModel):
|
||||
if old_allergens != list(parent.allergens.all()):
|
||||
parent.save(old_allergens=old_allergens)
|
||||
|
||||
@transaction.atomic
|
||||
def update_traces(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
old_traces = list(parent.traces.all()).copy()
|
||||
parent.traces.clear()
|
||||
for child in parent.ingredients.iterator():
|
||||
if child.pk != self.pk:
|
||||
parent.traces.set(parent.traces.union(child.traces.all()))
|
||||
parent.traces.set(parent.traces.union(self.traces.all()))
|
||||
if old_traces != list(parent.traces.all()):
|
||||
parent.save(old_traces=old_traces)
|
||||
|
||||
def update_expiry_date(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
@@ -138,6 +163,10 @@ class BasicFood(Food):
|
||||
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||
self.update_allergens()
|
||||
|
||||
if ('old_traces' in kwargs
|
||||
and list(self.traces.all()) != kwargs['old_traces']):
|
||||
self.update_traces()
|
||||
|
||||
# Expiry date
|
||||
if ((self.expiry_date != old_food.expiry_date
|
||||
and self.date_type == 'DLC')
|
||||
@@ -210,7 +239,7 @@ class TransformedFood(Food):
|
||||
created = self.pk is None
|
||||
if not created:
|
||||
# Check if important fields are updated
|
||||
update = {'allergens': False, 'expiry_date': False}
|
||||
update = {'allergens': False, 'traces': False, 'expiry_date': False}
|
||||
old_food = Food.objects.select_for_update().get(pk=self.pk)
|
||||
if not hasattr(self, "_force_save"):
|
||||
# Allergens
|
||||
@@ -220,6 +249,10 @@ class TransformedFood(Food):
|
||||
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||
update['allergens'] = True
|
||||
|
||||
if ('old_traces' in kwargs
|
||||
and list(self.traces.all()) != kwargs['old_traces']):
|
||||
update['traces'] = True
|
||||
|
||||
# Expiry date
|
||||
update['expiry_date'] = (self.shelf_life != old_food.shelf_life
|
||||
or self.creation_date != old_food.creation_date)
|
||||
@@ -230,6 +263,7 @@ class TransformedFood(Food):
|
||||
if ('old_ingredients' in kwargs
|
||||
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
|
||||
update['allergens'] = True
|
||||
update['traces'] = True
|
||||
update['expiry_date'] = True
|
||||
|
||||
# it's preferable to keep a queryset but we allow list too
|
||||
@@ -239,6 +273,8 @@ class TransformedFood(Food):
|
||||
self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
|
||||
if update['allergens']:
|
||||
self.update_allergens()
|
||||
if update['traces']:
|
||||
self.update_traces()
|
||||
if update['expiry_date']:
|
||||
self.update_expiry_date()
|
||||
|
||||
@@ -250,9 +286,10 @@ class TransformedFood(Food):
|
||||
|
||||
for child in self.ingredients.iterator():
|
||||
self.allergens.set(self.allergens.union(child.allergens.all()))
|
||||
self.traces.set(self.traces.union(child.traces.all()))
|
||||
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
|
||||
self.expiry_date = min(self.expiry_date, child.expiry_date)
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
return super().save(force_insert=False, force_update=force_update, using=using, update_fields=update_fields)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Transformed food')
|
||||
@@ -284,3 +321,250 @@ 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))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Check the owner of the food
|
||||
if self.food.owner != self.dish.main.owner:
|
||||
raise ValidationError(_('You cannot select food that belongs to the same club than the main food.'))
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
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):
|
||||
if self.activity != self.dish.activity:
|
||||
raise ValidationError(_('Activities must be the same.'))
|
||||
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
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
transaction = FoodTransaction(
|
||||
order=self,
|
||||
source=self.user.note,
|
||||
destination=self.activity.organizer.note,
|
||||
amount=self.amount,
|
||||
quantity=1,
|
||||
reason=str(self.dish),
|
||||
)
|
||||
transaction.save()
|
||||
else:
|
||||
old_object = Order.objects.get(pk=self.pk)
|
||||
if not old_object.served and self.served:
|
||||
self.served_at = timezone.now()
|
||||
self.transaction.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class FoodTransaction(Transaction):
|
||||
"""
|
||||
Special type of :model:`note.Transaction` associated to a :model:`food.Order`.
|
||||
"""
|
||||
order = models.OneToOneField(
|
||||
Order,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='transaction',
|
||||
verbose_name=_('order')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("food transaction")
|
||||
verbose_name_plural = _("food transactions")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.valid = self.order.served
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Recipe(models.Model):
|
||||
"""
|
||||
A recipe is a list of ingredients one can use to easily create a recurrent TransformedFood
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_("name"),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
ingredients_json = models.TextField(
|
||||
blank=True,
|
||||
default="[]",
|
||||
verbose_name=_("list of ingredients"),
|
||||
help_text=_("Ingredients of the recipe, encoded in JSON")
|
||||
)
|
||||
|
||||
creater = models.ForeignKey(
|
||||
Club,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("creater"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Recipe")
|
||||
verbose_name_plural = _("Recipes")
|
||||
unique_together = ('name', 'creater',)
|
||||
|
||||
def __str__(self):
|
||||
return "{name} ({creater})".format(name=self.name, creater=str(self.creater))
|
||||
|
||||
@property
|
||||
def ingredients(self):
|
||||
"""
|
||||
Ingredients are stored in a JSON string
|
||||
"""
|
||||
return json.loads(self.ingredients_json)
|
||||
|
||||
@ingredients.setter
|
||||
def ingredients(self, ingredients):
|
||||
"""
|
||||
Store ingredients as JSON string
|
||||
"""
|
||||
self.ingredients_json = json.dumps(ingredients, indent=2)
|
||||
|
||||
45
apps/food/static/food/js/order.js
Normal file
45
apps/food/static/food/js/order.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
|
||||
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, Recipe
|
||||
|
||||
|
||||
class FoodTable(tables.Table):
|
||||
@@ -29,9 +32,104 @@ class FoodTable(tables.Table):
|
||||
class Meta:
|
||||
model = Food
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date')
|
||||
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'traces', '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"), )
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('number', 'ordered_at', 'user', 'dish', 'supplements', 'request', 'serve', 'delete')
|
||||
order_by = ('ordered_at', )
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'style': 'cursor:pointer',
|
||||
}
|
||||
|
||||
|
||||
class RecipeTable(tables.Table):
|
||||
"""
|
||||
List all recipes
|
||||
"""
|
||||
def render_ingredients(self, record):
|
||||
return ", ".join(str(q) for q in record.ingredients)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('name', 'creater', 'ingredients',)
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'data-href': lambda record: str(record.pk),
|
||||
'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 %}
|
||||
44
apps/food/templates/food/dish_detail.html
Normal file
44
apps/food/templates/food/dish_detail.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% 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 %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url "food:dish_list" activity_pk=dish.activity.pk %}">
|
||||
{% trans "Return to dish list" %}
|
||||
</a>
|
||||
{% 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 "Add supplements (optional)" %}
|
||||
</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 %}
|
||||
@@ -47,6 +47,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
|
||||
{% trans "Manage ingredients" %}
|
||||
</a>
|
||||
{% if false %}
|
||||
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_use" pk=food.pk %}">
|
||||
{% trans "Use a recipe" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
|
||||
{% trans "Return to the food list" %}
|
||||
|
||||
@@ -64,13 +64,31 @@ 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 %}
|
||||
{% if false %}
|
||||
{% if can_view_recipes %}
|
||||
<a class="btn btn-sm btn-secondary" href="{% url 'food:recipe_list' %}">
|
||||
{% trans "View recipes" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_add_recipe %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'food:recipe_create' %}">
|
||||
{% trans "New recipe" %}
|
||||
</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 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if served.data %}
|
||||
{% render_table served %}
|
||||
{% else %}
|
||||
|
||||
41
apps/food/templates/food/kitchen.html
Normal file
41
apps/food/templates/food/kitchen.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 %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Colonne de plats -->
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem;">
|
||||
{% for food, quantity in orders.items %}
|
||||
<div class="card bg-white mb-3" style="flex: 1 1 calc(33.333% - 1rem); border: 1px solid #ccc; padding: 1rem; border-radius: 0.5rem; box-sizing: border-box;">
|
||||
|
||||
<h3 class="card-header text-center">
|
||||
<strong>{{ food }}</strong><br>
|
||||
</h3>
|
||||
<h1 class="card-body text-center">
|
||||
{{ quantity }}</h1>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Colonne de la table -->
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Special orders" %}
|
||||
</h3>
|
||||
{% if table.data %}
|
||||
{% render_table table %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There are no special orders." %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<th>{{ form.name.label }}</th>
|
||||
<th>{{ form.qrcode.label }}</th>
|
||||
<th>{{ form.fully_used.label }}</th>
|
||||
<th>{{ form.add_all_same_name.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="form_body">
|
||||
@@ -34,6 +35,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<td>{{ form.name }}</td>
|
||||
<td>{{ form.qrcode }}</td>
|
||||
<td>{{ form.fully_used }}</td>
|
||||
<td>{{ form.add_all_same_name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -88,7 +90,7 @@ function delete_form_data (form_id) {
|
||||
document.getElementById(prefix + "name").value = "";
|
||||
document.getElementById(prefix + "qrcode_pk").value = "";
|
||||
document.getElementById(prefix + "qrcode").value = "";
|
||||
document.getElementById(prefix + "fully_used").checked = true;
|
||||
document.getElementById(prefix + "fully_used").checked = false;
|
||||
}
|
||||
var form_count = {{ ingredients_count }} + 1;
|
||||
|
||||
|
||||
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%}
|
||||
31
apps/food/templates/food/recipe_detail.html
Normal file
31
apps/food/templates/food/recipe_detail.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% 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 }} {{ recipe.name }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li> {% trans "Creater" %} : {{ recipe.creater }}</li>
|
||||
<li> {% trans "Ingredients" %} :
|
||||
{% for ingredient in ingredients %} {{ ingredient }}{% if not forloop.last %},{% endif %}{% endfor %}
|
||||
</li>
|
||||
</ul>
|
||||
{% if update %}
|
||||
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_update" pk=recipe.pk %}">
|
||||
{% trans "Update" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url "food:recipe_list" %}">
|
||||
{% trans "Return to recipe list" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
122
apps/food/templates/food/recipe_form.html
Normal file
122
apps/food/templates/food/recipe_form.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% 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="" id="recipe_form">
|
||||
{% csrf_token %}
|
||||
<div class="card-body">
|
||||
{% crispy recipe_form %}
|
||||
{# Keep all form elements in the same card-body for proper structure #}
|
||||
{{ formset.management_form }}
|
||||
<h3 class="text-center mt-4">{% trans "Add ingredients" %}</h3>
|
||||
<table class="table table-condensed table-striped">
|
||||
{% for form in formset %}
|
||||
{% if forloop.first %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ form.name.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="form_body">
|
||||
{% endif %}
|
||||
<tr class="row-formset ingredients">
|
||||
<td>
|
||||
{# Force prefix on the form fields #}
|
||||
{{ form.name.as_widget }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{# Display buttons to add and remove ingredients #}
|
||||
<div class="card-body">
|
||||
<div class="btn-group btn-block" role="group">
|
||||
<button type="button" id="add_more" class="btn btn-success">{% trans "Add ingredient" %}</button>
|
||||
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove ingredient" %}</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" form="recipe_form">{% 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.name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
/* script that handles add and remove lines */
|
||||
$(document).ready(function() {
|
||||
const totalFormsInput = $('input[name$="-TOTAL_FORMS"]');
|
||||
const initialFormsInput = $('input[name$="-INITIAL_FORMS"]');
|
||||
|
||||
function updateTotalForms(n) {
|
||||
if (totalFormsInput.length) {
|
||||
totalFormsInput.val(n);
|
||||
}
|
||||
}
|
||||
|
||||
const initialCount = $('#form_body .row-formset').length;
|
||||
updateTotalForms(initialCount);
|
||||
|
||||
const foods = {{ ingredients | safe }};
|
||||
|
||||
function prepopulate () {
|
||||
for (var i = 0; i < {{ ingredients_count }}; i++) {
|
||||
let prefix = 'id_form-' + parseInt(i) + '-';
|
||||
document.getElementById(prefix + 'name').value = foods[i]['name'];
|
||||
};
|
||||
}
|
||||
prepopulate();
|
||||
|
||||
$('#add_more').click(function() {
|
||||
let formIdx = totalFormsInput.length ? parseInt(totalFormsInput.val(), 10) : $('#form_body .row-formset').length;
|
||||
let newForm = $('#for_real').html().replace(/__prefix__/g, formIdx);
|
||||
$('#form_body').append(newForm);
|
||||
updateTotalForms(formIdx + 1);
|
||||
});
|
||||
|
||||
$('#remove_one').click(function() {
|
||||
let formIdx = totalFormsInput.length ? parseInt(totalFormsInput.val(), 10) : $('#form_body .row-formset').length;
|
||||
if (formIdx > 1) {
|
||||
$('#form_body tr.row-formset:last').remove();
|
||||
updateTotalForms(formIdx - 1);
|
||||
}
|
||||
});
|
||||
|
||||
$('#recipe_form').on('submit', function() {
|
||||
const totalInput = $('input[name$="-TOTAL_FORMS"]');
|
||||
const prefix = totalInput.length ? totalInput.attr('name').replace(/-TOTAL_FORMS$/, '') : 'form';
|
||||
|
||||
$('#form_body tr.row-formset').each(function(i) {
|
||||
const input = $(this).find('input,select,textarea').first();
|
||||
if (input.length) {
|
||||
const newName = `${prefix}-${i}-name`;
|
||||
input.attr('name', newName).attr('id', `id_${newName}`).prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
const visibleCount = $('#form_body tr.row-formset').length;
|
||||
if (totalInput.length) totalInput.val(visibleCount);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
32
apps/food/templates/food/recipe_list.html
Normal file
32
apps/food/templates/food/recipe_list.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% 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 }}
|
||||
</h3>
|
||||
{% render_table table %}
|
||||
<div class="card-footer">
|
||||
{% if can_add_recipe %}
|
||||
<a class="btn btn-sm btn-success" href="{% url 'food:recipe_create' %}">{% trans "New recipe" %}</a>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
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 %}
|
||||
80
apps/food/templates/food/use_recipe_form.html
Normal file
80
apps/food/templates/food/use_recipe_form.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% 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 }} {{ object.name }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
function refreshIngredients() {
|
||||
// 1️⃣ on récupère l'id de la recette sélectionnée
|
||||
let recipe_id = $("#id_recipe").val() || $("input[name='recipe']:checked").val();
|
||||
|
||||
if (!recipe_id) {
|
||||
// 2️⃣ rien sélectionné → on vide la zone d'ingrédients
|
||||
$("#div_id_ingredients > div").empty().html("<em>Aucune recette sélectionnée</em>");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3️⃣ on interroge le serveur
|
||||
$.getJSON("{% url 'food:get_ingredients' %}", { recipe_id: recipe_id })
|
||||
.done(function (data) {
|
||||
|
||||
// 4️⃣ on cible le bon conteneur
|
||||
const $container = $("#div_id_ingredients > div");
|
||||
$container.empty();
|
||||
|
||||
if (data.ingredients && data.ingredients.length > 0) {
|
||||
// 5️⃣ on crée les cases à cocher
|
||||
data.ingredients.forEach(function (ing, i) {
|
||||
const html = `
|
||||
<div class="form-check">
|
||||
<input type="checkbox"
|
||||
name="ingredients"
|
||||
value="${ing.id}"
|
||||
id="id_ingredients_${i}"
|
||||
class="form-check-input"
|
||||
checked>
|
||||
<label class="form-check-label" for="id_ingredients_${i}">
|
||||
${ing.name} (${ing.qr_code_numbers})
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
$container.append(html);
|
||||
});
|
||||
} else {
|
||||
$container.html("<em>Aucun ingrédient trouvé</em>");
|
||||
}
|
||||
})
|
||||
.fail(function (xhr) {
|
||||
console.error("Erreur AJAX:", xhr);
|
||||
$("#div_id_ingredients > div").html("<em>Erreur de chargement des ingrédients</em>");
|
||||
});
|
||||
}
|
||||
|
||||
// 6️⃣ déclenche quand la recette change
|
||||
$("#id_recipe, input[name='recipe']").change(refreshIngredients);
|
||||
|
||||
// 7️⃣ initial
|
||||
refreshIngredients();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
0
apps/food/tests/__init__.py
Normal file
0
apps/food/tests/__init__.py
Normal file
@@ -6,9 +6,12 @@ from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from activity.models import Activity, ActivityType
|
||||
from member.models import Club
|
||||
|
||||
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
|
||||
from ..models import Allergen, BasicFood, TransformedFood, QRCode
|
||||
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \
|
||||
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
|
||||
from ..models import Allergen, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order # TODO FoodTransaction
|
||||
|
||||
|
||||
class TestFood(TestCase):
|
||||
@@ -53,73 +56,293 @@ class TestFood(TestCase):
|
||||
food_container=self.basicfood,
|
||||
)
|
||||
|
||||
def test_food_list(self):
|
||||
"""
|
||||
Display food list
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_food_list(self):
|
||||
"""
|
||||
Display food list
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_qrcode_create(self):
|
||||
"""
|
||||
Display QRCode creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:qrcode_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_qrcode_create(self):
|
||||
"""
|
||||
Display QRCode creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:qrcode_create', kwargs={"slug": 2}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_basicfood_create(self):
|
||||
"""
|
||||
Display BasicFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_basicfood_create(self):
|
||||
"""
|
||||
Display BasicFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_create', kwargs={"slug": 2}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_transformedfood_create(self):
|
||||
"""
|
||||
Display TransformedFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_transformedfood_create(self):
|
||||
"""
|
||||
Display TransformedFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_food_create(self):
|
||||
"""
|
||||
Display Food update
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_update'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_food_update(self):
|
||||
"""
|
||||
Display Food update
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_update', args=(self.basicfood.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_food_view(self):
|
||||
"""
|
||||
Display Food detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_view'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
def test_food_view(self):
|
||||
"""
|
||||
Display Food detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_view', args=(self.basicfood.pk,)))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_basicfood_view(self):
|
||||
"""
|
||||
Display BasicFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_view'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_basicfood_view(self):
|
||||
"""
|
||||
Display BasicFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_view', args=(self.basicfood.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_transformedfood_view(self):
|
||||
"""
|
||||
Display TransformedFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_view'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_transformedfood_view(self):
|
||||
"""
|
||||
Display TransformedFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_view', args=(self.transformedfood.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_ingredient(self):
|
||||
"""
|
||||
Display add ingredient view
|
||||
"""
|
||||
response = self.client.get(reverse('food:add_ingredient'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_add_ingredient(self):
|
||||
"""
|
||||
Display add ingredient view
|
||||
"""
|
||||
response = self.client.get(reverse('food:add_ingredient', args=(self.transformedfood.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
'''class TestFoodOrder(TestCase):
|
||||
"""
|
||||
Test Food Order
|
||||
"""
|
||||
fixtures = ('initial',)
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_superuser(
|
||||
username='admintoto',
|
||||
password='toto1234',
|
||||
email='toto@example.com'
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
sess = self.client.session
|
||||
sess['permission_mask'] = 42
|
||||
sess.save()
|
||||
|
||||
self.basicfood = BasicFood.objects.create(
|
||||
id=1,
|
||||
name='basicfood',
|
||||
owner=Club.objects.get(name="BDE"),
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
date_type='DLC',
|
||||
)
|
||||
|
||||
self.transformedfood = TransformedFood.objects.create(
|
||||
id=2,
|
||||
name='transformedfood',
|
||||
owner=Club.objects.get(name="BDE"),
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
)
|
||||
|
||||
self.second_transformedfood = TransformedFood.objects.create(
|
||||
id=3,
|
||||
name='second transformedfood',
|
||||
owner=Club.objects.get(name="BDE"),
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
)
|
||||
|
||||
self.third_transformedfood = TransformedFood.objects.create(
|
||||
id=4,
|
||||
name='third transformedfood',
|
||||
owner=Club.objects.get(name="BDE"),
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
)
|
||||
|
||||
self.activity = Activity.objects.create(
|
||||
activity_type=ActivityType.objects.get(name="Perm bouffe"),
|
||||
organizer=Club.objects.get(name="BDE"),
|
||||
creater=self.user,
|
||||
attendees_club_id=1,
|
||||
date_start=timezone.now(),
|
||||
date_end=timezone.now(),
|
||||
name="Test activity",
|
||||
open=True,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
self.dish = Dish.objects.create(
|
||||
main=self.transformedfood,
|
||||
price=500,
|
||||
activity=self.activity,
|
||||
available=True,
|
||||
)
|
||||
|
||||
self.second_dish = Dish.objects.create(
|
||||
main=self.second_transformedfood,
|
||||
price=1000,
|
||||
activity=self.activity,
|
||||
available=True,
|
||||
)
|
||||
|
||||
self.supplement = Supplement.objects.create(
|
||||
dish=self.dish,
|
||||
food=self.basicfood,
|
||||
price=100,
|
||||
)
|
||||
|
||||
self.order = Order.objects.create(
|
||||
user=self.user,
|
||||
activity=self.activity,
|
||||
dish=self.dish,
|
||||
)
|
||||
self.order.supplements.add(self.supplement)
|
||||
self.order.save()
|
||||
|
||||
def test_dish_list(self):
|
||||
"""
|
||||
Try to display dish list
|
||||
"""
|
||||
response = self.client.get(reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_dish_create(self):
|
||||
"""
|
||||
Try to create a dish
|
||||
"""
|
||||
response = self.client.get(reverse("food:dish_create", kwargs={"activity_pk": self.activity.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("food:dish_create", kwargs={"activity_pk": self.activity.pk}), data={
|
||||
"main": self.third_transformedfood.pk,
|
||||
"price": 4,
|
||||
"activity": self.activity.pk,
|
||||
"supplements-0-food": self.basicfood.pk,
|
||||
"supplements-0-price": 0.5,
|
||||
"supplements-TOTAL_FORMS": 1,
|
||||
"supplements-INITIAL_FORMS": 0,
|
||||
"supplements-MIN_NUM_FORMS": 0,
|
||||
"supplements-MAX_NUM_FORMS": 1000,
|
||||
})
|
||||
self.assertRedirects(response, reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}), 302, 200)
|
||||
self.assertTrue(Dish.objects.filter(main=self.third_transformedfood).exists())
|
||||
self.assertTrue(Supplement.objects.filter(food=self.basicfood, price=50).exists())
|
||||
|
||||
def test_dish_update(self):
|
||||
"""
|
||||
Try to update a dish
|
||||
"""
|
||||
response = self.client.get(reverse("food:dish_update", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("food:dish_update", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}), data={
|
||||
"price": 6,
|
||||
"supplements-0-food": self.basicfood.pk,
|
||||
"supplements-0-price": 1,
|
||||
"supplements-1-food": self.basicfood.pk,
|
||||
"supplements-1-price": 0.25,
|
||||
"supplements-TOTAL_FORMS": 2,
|
||||
"supplements-INITIAL_FORMS": 0,
|
||||
"supplements-MIN_NUM_FORMS": 0,
|
||||
"supplements-MAX_NUM_FORMS": 1000,
|
||||
})
|
||||
self.assertRedirects(response, reverse("food:dish_detail", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}), 302, 200)
|
||||
self.dish.refresh_from_db()
|
||||
self.assertTrue(Dish.objects.filter(main=self.transformedfood, price=600).exists())
|
||||
self.assertTrue(Supplement.objects.filter(dish=self.dish, food=self.basicfood, price=25).exists())
|
||||
|
||||
def test_dish_detail(self):
|
||||
"""
|
||||
Try to display dish details
|
||||
"""
|
||||
response = self.client.get(reverse("food:dish_detail", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_dish_delete(self):
|
||||
"""
|
||||
Try to delete a dish
|
||||
"""
|
||||
response = self.client.get(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Cannot delete already ordered Dish
|
||||
response = self.client.delete(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertTrue(Dish.objects.filter(pk=self.dish.pk).exists())
|
||||
|
||||
# Can delete a Dish with no order
|
||||
response = self.client.delete(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.second_dish.pk}))
|
||||
self.assertRedirects(response, reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}))
|
||||
self.assertFalse(Dish.objects.filter(pk=self.second_dish.pk).exists())
|
||||
|
||||
def test_order_food(self):
|
||||
"""
|
||||
Try to make an order
|
||||
"""
|
||||
response = self.client.get(reverse("food:order_create", kwargs={"activity_pk": self.activity.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("food:order_create", kwargs={"activity_pk": self.activity.pk}), data=dict(
|
||||
user=self.user.pk,
|
||||
activity=self.activity.pk,
|
||||
dish=self.second_dish.pk,
|
||||
supplements=self.supplement.pk
|
||||
))
|
||||
self.assertRedirects(response, reverse("food:food_list"))
|
||||
self.assertTrue(Order.objects.filter(user=self.user, dish=self.second_dish, activity=self.activity).exists())
|
||||
|
||||
def test_order_list(self):
|
||||
"""
|
||||
Try to display order list
|
||||
"""
|
||||
response = self.client.get(reverse("food:order_list", kwargs={"activity_pk": self.activity.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_served_order_list(self):
|
||||
"""
|
||||
Try to display served order list
|
||||
"""
|
||||
response = self.client.get(reverse("food:served_order_list", kwargs={"activity_pk": self.activity.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_serve_order(self):
|
||||
"""
|
||||
Try to serve an order, then to unserve it
|
||||
"""
|
||||
response = self.client.patch("/api/food/order/" + str(self.order.pk) + "/", data=dict(
|
||||
served=True
|
||||
), content_type="application/json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.order.refresh_from_db()
|
||||
self.assertTrue(Order.objects.filter(dish=self.dish, user=self.user, served=True).exists())
|
||||
self.assertIsNotNone(self.order.served_at)
|
||||
|
||||
self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=True).exists())
|
||||
|
||||
response = self.client.patch("/api/food/order/" + str(self.order.pk) + "/", data=dict(
|
||||
served=False
|
||||
), content_type="application/json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(Order.objects.filter(dish=self.dish, user=self.user, served=False).exists())
|
||||
|
||||
self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=False).exists())'''
|
||||
|
||||
|
||||
class TestFoodAPI(TestAPI):
|
||||
def setUp(self) -> None:
|
||||
super().setUP()
|
||||
super().setUp()
|
||||
|
||||
self.allergen = Allergen.objects.create(
|
||||
name='name',
|
||||
@@ -145,26 +368,84 @@ class TestFoodAPI(TestAPI):
|
||||
food_container=self.basicfood,
|
||||
)
|
||||
|
||||
def test_allergen_api(self):
|
||||
"""
|
||||
Load Allergen API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
|
||||
self.activity = Activity.objects.create(
|
||||
activity_type=ActivityType.objects.get(name="Perm bouffe"),
|
||||
organizer=Club.objects.get(name="BDE"),
|
||||
creater=self.user,
|
||||
attendees_club_id=1,
|
||||
date_start=timezone.now(),
|
||||
date_end=timezone.now(),
|
||||
name="Test activity",
|
||||
open=True,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
def test_basicfood_api(self):
|
||||
"""
|
||||
Load BasicFood API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
|
||||
self.dish = Dish.objects.create(
|
||||
main=self.transformedfood,
|
||||
price=500,
|
||||
activity=self.activity,
|
||||
available=True,
|
||||
)
|
||||
|
||||
self.supplement = Supplement.objects.create(
|
||||
dish=self.dish,
|
||||
food=self.basicfood,
|
||||
price=100,
|
||||
)
|
||||
|
||||
self.order = Order.objects.create(
|
||||
user=self.user,
|
||||
activity=self.activity,
|
||||
dish=self.dish,
|
||||
)
|
||||
self.order.supplements.add(self.supplement)
|
||||
self.order.save()
|
||||
|
||||
def test_allergen_api(self):
|
||||
"""
|
||||
Load Allergen API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
|
||||
|
||||
def test_basicfood_api(self):
|
||||
"""
|
||||
Load BasicFood API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
|
||||
|
||||
# TODO Repair and detabulate this test
|
||||
def test_transformedfood_api(self):
|
||||
"""
|
||||
Load TransformedFood API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
|
||||
|
||||
def test_qrcode_api(self):
|
||||
"""
|
||||
Load QRCode API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')
|
||||
def test_qrcode_api(self):
|
||||
"""
|
||||
Load QRCode API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')
|
||||
|
||||
def test_dish_api(self):
|
||||
"""
|
||||
Load Dish API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(DishViewSet, '/api/food/dish/')
|
||||
|
||||
def test_supplement_api(self):
|
||||
"""
|
||||
Load Supplement API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(SupplementViewSet, '/api/food/supplement/')
|
||||
|
||||
def test_order_api(self):
|
||||
"""
|
||||
Load Order API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(OrderViewSet, '/api/food/order/')
|
||||
|
||||
def test_foodtransaction_api(self):
|
||||
"""
|
||||
Load FoodTransaction API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(FoodTransactionViewSet, '/api/food/foodtransaction/')
|
||||
|
||||
@@ -9,14 +9,30 @@ app_name = 'food'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.FoodListView.as_view(), name='food_list'),
|
||||
path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||
path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
|
||||
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
|
||||
path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
|
||||
path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
|
||||
path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
|
||||
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
|
||||
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
|
||||
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
||||
path('<int:slug>/', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||
path('<int:slug>/add/basic/', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
|
||||
path('add/transformed/', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
|
||||
path('update/<int:pk>/', views.FoodUpdateView.as_view(), name='food_update'),
|
||||
path('update/ingredients/<int:pk>/', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
|
||||
path('detail/<int:pk>/', views.FoodDetailView.as_view(), name='food_view'),
|
||||
path('detail/basic/<int:pk>/', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
|
||||
path('detail/transformed/<int:pk>/', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
|
||||
path('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/<int:activity_pk>/kitchen/', views.KitchenView.as_view(), name='kitchen'),
|
||||
# path('recipe/add/', views.RecipeCreateView.as_view(), name='recipe_create'),
|
||||
# path('recipe/', views.RecipeListView.as_view(), name='recipe_list'),
|
||||
# path('recipe/<int:pk>/', views.RecipeDetailView.as_view(), name='recipe_detail'),
|
||||
# path('recipe/<int:pk>/update/', views.RecipeUpdateView.as_view(), name='recipe_update'),
|
||||
# path('update/ingredients/<int:pk>/recipe/', views.UseRecipeView.as_view(), name='recipe_use'),
|
||||
# path('ajax/get_ingredients/', views.get_ingredients_for_recipe, name='get_ingredients'),
|
||||
]
|
||||
|
||||
@@ -4,25 +4,32 @@
|
||||
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.db.models import Q, Count
|
||||
from django.http import HttpResponseRedirect, Http404, JsonResponse
|
||||
from django.views.decorators.http import require_GET
|
||||
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, Supplement, Recipe
|
||||
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
|
||||
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
|
||||
BasicFoodUpdateForms, TransformedFoodUpdateForms
|
||||
from .tables import FoodTable
|
||||
BasicFoodUpdateForms, TransformedFoodUpdateForms, \
|
||||
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm, RecipeForm, \
|
||||
RecipeIngredientsForm, RecipeIngredientsFormSet, UseRecipeForm
|
||||
from .tables import FoodTable, DishTable, OrderTable, RecipeTable
|
||||
from .utils import pretty_duration
|
||||
|
||||
|
||||
@@ -116,6 +123,13 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
|
||||
context['club_tables'] = tables[3:]
|
||||
|
||||
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
|
||||
|
||||
context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
|
||||
|
||||
context['can_view_recipes'] = PermissionBackend.check_perm(self.request, 'food.recipe_view')
|
||||
|
||||
context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -231,6 +245,8 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
for field in context['form'].fields:
|
||||
if field == 'allergens':
|
||||
context['form'].fields[field].initial = getattr(food, field).all()
|
||||
elif field == 'traces':
|
||||
context['form'].fields[field].initial = getattr(food, field).all()
|
||||
else:
|
||||
context['form'].fields[field].initial = getattr(food, field)
|
||||
|
||||
@@ -290,34 +306,42 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
|
||||
def form_valid(self, form):
|
||||
old_ingredients = list(self.object.ingredients.all()).copy()
|
||||
old_allergens = list(self.object.allergens.all()).copy()
|
||||
old_traces = list(self.object.traces.all()).copy()
|
||||
self.object.ingredients.clear()
|
||||
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
|
||||
prefix = 'form-' + str(i) + '-'
|
||||
if form.data[prefix + 'qrcode'] not in ['0', '']:
|
||||
|
||||
ingredient = None
|
||||
if form.data[prefix + 'qrcode'] not in ['0', '', 'NaN']:
|
||||
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container
|
||||
|
||||
elif form.data[prefix + 'name'] != '':
|
||||
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
|
||||
|
||||
if form.data.get(prefix + 'add_all_same_name') == 'on':
|
||||
ingredients = Food.objects.filter(name=ingredient.name, owner=ingredient.owner, end_of_life='')
|
||||
else:
|
||||
ingredients = [ingredient]
|
||||
|
||||
for ingredient in ingredients:
|
||||
self.object.ingredients.add(ingredient)
|
||||
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
|
||||
ingredient.end_of_life = _('Fully used in {meal}'.format(
|
||||
meal=self.object.name))
|
||||
ingredient.save()
|
||||
|
||||
elif form.data[prefix + 'name'] != '':
|
||||
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
|
||||
self.object.ingredients.add(ingredient)
|
||||
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
|
||||
ingredient.end_of_life = _('Fully used in {meal}'.format(
|
||||
meal=self.object.name))
|
||||
ingredient.save()
|
||||
# We recalculate new expiry date and allergens
|
||||
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
|
||||
self.object.allergens.clear()
|
||||
self.object.traces.clear()
|
||||
|
||||
for ingredient in self.object.ingredients.iterator():
|
||||
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
|
||||
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
|
||||
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
|
||||
self.object.traces.set(self.object.traces.union(ingredient.traces.all()))
|
||||
|
||||
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
|
||||
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
@@ -341,6 +365,7 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
|
||||
'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number,
|
||||
'fully_used': 'true' if ingredient.end_of_life else '',
|
||||
})
|
||||
|
||||
return context
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
@@ -369,13 +394,15 @@ class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
for meal in meals:
|
||||
old_ingredients = list(meal.ingredients.all()).copy()
|
||||
old_allergens = list(meal.allergens.all()).copy()
|
||||
old_traces = list(meal.traces.all()).copy()
|
||||
meal.ingredients.add(self.object.pk)
|
||||
# update allergen and expiry date if necessary
|
||||
if not (self.object.polymorphic_ctype.model == 'basicfood'
|
||||
and self.object.date_type == 'DDM'):
|
||||
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
|
||||
meal.allergens.set(meal.allergens.union(self.object.allergens.all()))
|
||||
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
|
||||
meal.traces.set(meal.traces.union(self.object.traces.all()))
|
||||
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
|
||||
if 'fully_used' in form.data:
|
||||
if not self.object.end_of_life:
|
||||
self.object.end_of_life = _(f'Food fully used in : {meal.name}')
|
||||
@@ -405,6 +432,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
form.instance.creater = self.request.user
|
||||
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||
old_allergens = list(food.allergens.all()).copy()
|
||||
old_traces = list(food.traces.all()).copy()
|
||||
|
||||
if food.polymorphic_ctype.model == 'transformedfood':
|
||||
old_ingredients = food.ingredients.all()
|
||||
@@ -418,7 +446,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
if food.polymorphic_ctype.model == 'transformedfood':
|
||||
form.instance.save(old_ingredients=old_ingredients)
|
||||
else:
|
||||
form.instance.save(old_allergens=old_allergens)
|
||||
form.instance.save(old_allergens=old_allergens, old_traces=old_traces)
|
||||
return ans
|
||||
|
||||
def get_form_class(self, **kwargs):
|
||||
@@ -451,7 +479,7 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"]
|
||||
fields = ["name", "owner", "expiry_date", "allergens", "traces", "is_ready", "end_of_life", "order"]
|
||||
|
||||
fields = dict([(field, getattr(self.object, field)) for field in fields])
|
||||
if fields["is_ready"]:
|
||||
@@ -460,6 +488,8 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
fields["is_ready"] = _("No")
|
||||
fields["allergens"] = ", ".join(
|
||||
allergen.name for allergen in fields["allergens"].all())
|
||||
fields["traces"] = ", ".join(
|
||||
trace.name for trace in fields["traces"].all())
|
||||
|
||||
context["fields"] = [(
|
||||
Food._meta.get_field(field).verbose_name.capitalize(),
|
||||
@@ -530,3 +560,520 @@ 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:
|
||||
# We don't save the product if the price is not entered, ie. if the line is empty
|
||||
if f.is_valid() and f.instance.price:
|
||||
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_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 'main' in form.fields:
|
||||
del form.fields["main"]
|
||||
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)
|
||||
saved = []
|
||||
if formset.is_valid():
|
||||
for f in formset:
|
||||
# We don't save the product if the price is not entered, ie. if the line is empty
|
||||
if f.is_valid() and f.instance.price:
|
||||
f.save()
|
||||
f.instance.save()
|
||||
saved.append(f.instance.pk)
|
||||
else:
|
||||
f.instance = None
|
||||
# Remove old supplements that weren't given in the form
|
||||
Supplement.objects.filter(~Q(pk__in=saved), dish=form.instance).delete()
|
||||
|
||||
return ret
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('food:dish_detail', kwargs={"activity_pk": self.kwargs["activity_pk"], "pk": self.kwargs["pk"]})
|
||||
|
||||
|
||||
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).order_by('number')
|
||||
|
||||
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 KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
The view to display useful information for the kitchen
|
||||
"""
|
||||
model = Order
|
||||
table_class = OrderTable
|
||||
template_name = 'food/kitchen.html'
|
||||
extra_context = {'title': _('Kitchen')}
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(~Q(supplements__isnull=True, request=''), activity__pk=self.kwargs["activity_pk"], served=False)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
orders_count = Order.objects.filter(activity__pk=self.kwargs["activity_pk"], served=False).values('dish__main__name').annotate(quantity=Count('id'))
|
||||
|
||||
context["orders"] = {o['dish__main__name']: o['quantity'] for o in orders_count}
|
||||
|
||||
return context
|
||||
|
||||
def get_table(self, **kwargs):
|
||||
table = super().get_table(**kwargs)
|
||||
|
||||
hide = ["ordered_at", "serve", "delete"]
|
||||
for field in hide:
|
||||
table.columns.hide(field)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
class RecipeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
Create a recipe
|
||||
"""
|
||||
model = Recipe
|
||||
form_class = RecipeForm
|
||||
extra_context = {"title": _("Create a recipe")}
|
||||
|
||||
def get_sample_object(self):
|
||||
return Recipe(name='Sample recipe')
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
formset = RecipeIngredientsFormSet(self.request.POST)
|
||||
if formset.is_valid():
|
||||
ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
|
||||
self.object = form.save(commit=False)
|
||||
self.object.ingredients = ingredients
|
||||
self.object.save()
|
||||
return super().form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['form'] = RecipeIngredientsForm()
|
||||
context['recipe_form'] = self.get_form()
|
||||
if self.request.POST:
|
||||
context['formset'] = RecipeIngredientsFormSet(self.request.POST,)
|
||||
else:
|
||||
context['formset'] = RecipeIngredientsFormSet()
|
||||
return context
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('food:recipe_list')
|
||||
|
||||
|
||||
class RecipeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
List all recipes
|
||||
"""
|
||||
model = Recipe
|
||||
table_class = RecipeTable
|
||||
extra_context = {"title": _('All recipes')}
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
|
||||
context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class RecipeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
List all recipes
|
||||
"""
|
||||
model = Recipe
|
||||
extra_context = {"title": _('Details of:')}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["ingredients"] = self.object.ingredients
|
||||
context["update"] = PermissionBackend.check_perm(self.request, "food.change_recipe")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class RecipeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Create a recipe
|
||||
"""
|
||||
model = Recipe
|
||||
form_class = RecipeForm
|
||||
extra_context = {"title": _("Create a recipe")}
|
||||
|
||||
def get_sample_object(self):
|
||||
return Recipe(name='Sample recipe')
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
formset = RecipeIngredientsFormSet(self.request.POST)
|
||||
if formset.is_valid():
|
||||
ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
|
||||
self.object = form.save(commit=False)
|
||||
self.object.ingredients = ingredients
|
||||
self.object.save()
|
||||
return super().form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['form'] = RecipeIngredientsForm()
|
||||
context['recipe_form'] = self.get_form()
|
||||
if self.request.POST:
|
||||
formset = RecipeIngredientsFormSet(self.request.POST,)
|
||||
else:
|
||||
formset = RecipeIngredientsFormSet()
|
||||
ingredients = self.object.ingredients
|
||||
context["ingredients_count"] = len(ingredients)
|
||||
formset.extra += len(ingredients)
|
||||
context["formset"] = formset
|
||||
context["ingredients"] = []
|
||||
for ingredient in ingredients:
|
||||
context["ingredients"].append({"name": ingredient})
|
||||
return context
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('food:recipe_detail', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class UseRecipeView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Add ingredients to a TransformedFood using a Recipe
|
||||
"""
|
||||
model = TransformedFood
|
||||
fields = ('ingredients',)
|
||||
template_name = 'food/use_recipe_form.html'
|
||||
extra_context = {"title": _("Use a recipe for:")}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["form"] = UseRecipeForm()
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
old_ingredients = list(self.object.ingredients.all()).copy()
|
||||
old_allergens = list(self.object.allergens.all()).copy()
|
||||
old_traces = list(self.object.traces.all()).copy()
|
||||
if "ingredients" in form.data:
|
||||
ingredients_pk = form.data.getlist("ingredients")
|
||||
ingredients = Food.objects.all().filter(pk__in=ingredients_pk)
|
||||
for ingredient in ingredients:
|
||||
self.object.ingredients.add(ingredient)
|
||||
|
||||
# We recalculate new expiry date and allergens
|
||||
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
|
||||
self.object.allergens.clear()
|
||||
self.object.traces.clear()
|
||||
|
||||
for ingredient in self.object.ingredients.iterator():
|
||||
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
|
||||
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
|
||||
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
|
||||
self.object.traces.set(self.object.traces.union(ingredient.traces.all()))
|
||||
|
||||
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
@require_GET
|
||||
def get_ingredients_for_recipe(request):
|
||||
recipe_id = request.GET.get('recipe_id')
|
||||
if not recipe_id:
|
||||
return JsonResponse({'error': 'Missing recipe_id'}, status=400)
|
||||
|
||||
try:
|
||||
recipe = Recipe.objects.get(pk=recipe_id)
|
||||
except Recipe.DoesNotExist:
|
||||
return JsonResponse({'error': 'Recipe not found'}, status=404)
|
||||
|
||||
# 🔧 Supporte les deux cas : ManyToMany ou simple liste
|
||||
ingredients_field = recipe.ingredients
|
||||
|
||||
if hasattr(ingredients_field, "values_list"):
|
||||
# Cas ManyToManyField
|
||||
ingredient_names = list(ingredients_field.values_list('name', flat=True))
|
||||
elif isinstance(ingredients_field, (list, tuple)):
|
||||
# Cas liste directe
|
||||
ingredient_names = ingredients_field
|
||||
else:
|
||||
return JsonResponse({'error': 'Unsupported ingredients type'}, status=500)
|
||||
|
||||
# Union des Foods dont le nom commence par un nom d’ingrédient
|
||||
query = Q()
|
||||
for name in ingredient_names:
|
||||
valid_regex = is_regex(name)
|
||||
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||
prefix = '.*' if valid_regex else ''
|
||||
query |= Q(**{f'name{suffix}': prefix + name}, end_of_life='')
|
||||
qs = Food.objects.filter(query).distinct()
|
||||
qs = qs.filter(PermissionBackend.filter_queryset(request, Food, 'view'))
|
||||
|
||||
data = [{'id': f.id, 'name': f.name, 'qr_code_numbers': ", ".join(str(q.qr_code_number) for q in f.QR_code.all())} for f in qs]
|
||||
return JsonResponse({'ingredients': data})
|
||||
|
||||
@@ -26,24 +26,42 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
@staticmethod
|
||||
@memoize
|
||||
def get_raw_permissions(request, t):
|
||||
def get_raw_permissions(request, t): # noqa: C901
|
||||
"""
|
||||
Query permissions of a certain type for a user, then memoize it.
|
||||
:param request: The current request
|
||||
:param t: The type of the permissions: view, change, add or delete
|
||||
:return: The queryset of the permissions of the user (memoized) grouped by clubs
|
||||
"""
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# Permission for auth
|
||||
if hasattr(request, 'oauth2') and request.oauth2 is not None and 'scope' in request.oauth2:
|
||||
# OAuth2 Authentication
|
||||
user = request.oauth2['user']
|
||||
|
||||
def permission_filter(membership_obj):
|
||||
query = Q(pk=-1)
|
||||
for scope in request.oauth2['scope']:
|
||||
if scope == "openid":
|
||||
continue
|
||||
permission_id, club_id = scope.split('_')
|
||||
if int(club_id) == membership_obj.club_id:
|
||||
query |= Q(pk=permission_id, mask__rank__lte=request.oauth2['mask'])
|
||||
return query
|
||||
|
||||
# Restreint token permission to his scope
|
||||
elif hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
user = request.auth.user
|
||||
|
||||
def permission_filter(membership_obj):
|
||||
query = Q(pk=-1)
|
||||
for scope in request.auth.scope.split(' '):
|
||||
if scope == "openid" or scope == "0_0":
|
||||
continue
|
||||
permission_id, club_id = scope.split('_')
|
||||
if int(club_id) == membership_obj.club_id:
|
||||
query |= Q(pk=permission_id)
|
||||
return query
|
||||
|
||||
else:
|
||||
user = request.user
|
||||
|
||||
@@ -77,7 +95,6 @@ class PermissionBackend(ModelBackend):
|
||||
:param type: The type of the permissions: view, change, add or delete
|
||||
:return: A generator of the requested permissions
|
||||
"""
|
||||
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# OAuth2 Authentication
|
||||
user = request.auth.user
|
||||
|
||||
@@ -4734,6 +4734,201 @@
|
||||
"description": "Voir l'adresse mail des membres de son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 331,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"dish"
|
||||
],
|
||||
"query": "{\"activity__organizer\": [\"club\"]}",
|
||||
"type": "create",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Créer un plat vendu par son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 332,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"dish"
|
||||
],
|
||||
"query": "{\"activity__organizer\": [\"club\"]}",
|
||||
"type": "change",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Modifier un plat vendu par son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 333,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"dish"
|
||||
],
|
||||
"query": "{\"activity__organizer\": [\"club\"]}",
|
||||
"type": "view",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Voir les plats vendus par son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 334,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"dish"
|
||||
],
|
||||
"query": "[\"AND\", {\"activity__open\": true}, {\"available\": true}]",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"permanent": false,
|
||||
"description": "Voir les plats disponibles"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 335,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"supplement"
|
||||
],
|
||||
"query": "{\"dish__main__owner\": [\"club\"]}",
|
||||
"type": "create",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Ajouter un supplément à un plat de son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 336,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"supplement"
|
||||
],
|
||||
"query": "{\"dish__main__owner\": [\"club\"]}",
|
||||
"type": "change",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Modifier un supplément d'un plat de son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 337,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"supplement"
|
||||
],
|
||||
"query": "{\"dish__main__owner\": [\"club\"]}",
|
||||
"type": "view",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Voir les suppléments des plats de son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 337,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"supplement"
|
||||
],
|
||||
"query": "[\"AND\", {\"dish__activity__open\": true}, {\"dish__available\": true}]",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"permanent": false,
|
||||
"description": "Voir les suppléments des plats disponibles"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 338,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"supplement"
|
||||
],
|
||||
"query": "{\"dish__main__owner\": [\"club\"]}",
|
||||
"type": "delete",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Supprimer un supplément d'un plat de son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 339,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"order"
|
||||
],
|
||||
"query": "[\"AND\", {\"dish__activity__open\": true, \"dish__available\": true}, {\"user\": [\"user\"]}]",
|
||||
"type": "create",
|
||||
"mask": 1,
|
||||
"permanent": false,
|
||||
"description": "Commander un plat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 340,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"order"
|
||||
],
|
||||
"query": "[\"AND\", {\"dish__activity__open\": true}, {\"user\": [\"user\"]}]",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"permanent": false,
|
||||
"description": "Voir ses commandes pour les activités ouvertes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 341,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"order"
|
||||
],
|
||||
"query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
|
||||
"type": "view",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Voir toutes les commandes pour les activités ouvertes de son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 342,
|
||||
"fields": {
|
||||
"model": [
|
||||
"food",
|
||||
"order"
|
||||
],
|
||||
"query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
|
||||
"type": "change",
|
||||
"mask": 2,
|
||||
"permanent": false,
|
||||
"description": "Modifier un commande non servie d'une activité de son club"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.role",
|
||||
"pk": 1,
|
||||
|
||||
@@ -10,6 +10,8 @@ from note_kfet.middlewares import get_current_request
|
||||
from .backends import PermissionBackend
|
||||
from .models import Permission
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class PermissionScopes(BaseScopes):
|
||||
"""
|
||||
@@ -23,7 +25,9 @@ class PermissionScopes(BaseScopes):
|
||||
if 'scopes' in kwargs:
|
||||
for scope in kwargs['scopes']:
|
||||
if scope == 'openid':
|
||||
scopes['openid'] = "OpenID Connect"
|
||||
scopes['openid'] = _("OpenID Connect (username and email)")
|
||||
elif scope == '0_0':
|
||||
scopes['0_0'] = _("Useless scope which do nothing")
|
||||
else:
|
||||
p = Permission.objects.get(id=scope.split('_')[0])
|
||||
club = Club.objects.get(id=scope.split('_')[1])
|
||||
@@ -32,7 +36,8 @@ class PermissionScopes(BaseScopes):
|
||||
|
||||
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
||||
scopes['openid'] = "OpenID Connect"
|
||||
scopes['openid'] = _("OpenID Connect (username and email)")
|
||||
scopes['0_0'] = _("Useless scope which do nothing")
|
||||
return scopes
|
||||
|
||||
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
@@ -41,7 +46,7 @@ class PermissionScopes(BaseScopes):
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for t in Permission.PERMISSION_TYPES
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||
scopes.append('openid')
|
||||
scopes.append('0_0') # always available
|
||||
return scopes
|
||||
|
||||
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
@@ -49,7 +54,7 @@ class PermissionScopes(BaseScopes):
|
||||
return []
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||
scopes.append('openid')
|
||||
scopes = ['0_0'] # always default
|
||||
return scopes
|
||||
|
||||
|
||||
@@ -67,10 +72,77 @@ class PermissionOAuth2Validator(OAuth2Validator):
|
||||
"email": request.user.email,
|
||||
}
|
||||
|
||||
def get_userinfo_claims(self, request):
|
||||
claims = super().get_userinfo_claims(request)
|
||||
claims['is_active'] = request.user.is_active
|
||||
return claims
|
||||
|
||||
def get_discovery_claims(self, request):
|
||||
claims = super().get_discovery_claims(self)
|
||||
return claims + ["name", "normalized_name", "email"]
|
||||
|
||||
def validate_client_credentials_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
For client credentials valid scopes are scope of the app owner
|
||||
"""
|
||||
valid_scopes = set()
|
||||
request.oauth2 = {}
|
||||
request.oauth2['user'] = client.user
|
||||
request.oauth2['user'].is_anomymous = False
|
||||
request.oauth2['scope'] = scopes
|
||||
# mask implementation
|
||||
if hasattr(request.decoded_body, 'mask'):
|
||||
try:
|
||||
request.oauth2['mask'] = int(request.decoded_body['mask'])
|
||||
except ValueError:
|
||||
request.oauth2['mask'] = 42
|
||||
else:
|
||||
request.oauth2['mask'] = 42
|
||||
|
||||
for t in Permission.PERMISSION_TYPES:
|
||||
for p in PermissionBackend.get_raw_permissions(request, t[0]):
|
||||
scope = f"{p.id}_{p.membership.club.id}"
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
# Always give one scope to generate token
|
||||
if not valid_scopes:
|
||||
valid_scopes.add('0_0')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
||||
def validate_ropb_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
For ROPB valid scopes are scope of the user
|
||||
"""
|
||||
valid_scopes = set()
|
||||
request.oauth2 = {}
|
||||
request.oauth2['user'] = request.user
|
||||
request.oauth2['user'].is_anomymous = False
|
||||
request.oauth2['scope'] = scopes
|
||||
# mask implementation
|
||||
if hasattr(request.decoded_body, 'mask'):
|
||||
try:
|
||||
request.oauth2['mask'] = int(request.decoded_body['mask'])
|
||||
except ValueError:
|
||||
request.oauth2['mask'] = 42
|
||||
else:
|
||||
request.oauth2['mask'] = 42
|
||||
|
||||
for t in Permission.PERMISSION_TYPES:
|
||||
for p in PermissionBackend.get_raw_permissions(request, t[0]):
|
||||
scope = f"{p.id}_{p.membership.club.id}"
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
# Always give one scope to generate token
|
||||
if not valid_scopes:
|
||||
valid_scopes.add('0_0')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
User can request as many scope as he wants, including invalid scopes,
|
||||
@@ -79,17 +151,35 @@ class PermissionOAuth2Validator(OAuth2Validator):
|
||||
This allows clients to request more permission to get finally a
|
||||
subset of permissions.
|
||||
"""
|
||||
valid_scopes = set()
|
||||
if hasattr(request, 'grant_type') and request.grant_type == 'client_credentials':
|
||||
return self.validate_client_credentials_scopes(client_id, scopes, client, request, args, kwargs)
|
||||
if hasattr(request, 'grant_type') and request.grant_type == 'password':
|
||||
return self.validate_ropb_scopes(client_id, scopes, client, request, args, kwargs)
|
||||
|
||||
# Authorization code and Implicit are the same for scope, OIDC it's only a layer
|
||||
|
||||
valid_scopes = set()
|
||||
req = get_current_request()
|
||||
request.oauth2 = {}
|
||||
request.oauth2['user'] = req.user
|
||||
request.oauth2['scope'] = scopes
|
||||
# mask implementation
|
||||
request.oauth2['mask'] = req.session.load()['permission_mask']
|
||||
|
||||
for t in Permission.PERMISSION_TYPES:
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0]):
|
||||
for p in PermissionBackend.get_raw_permissions(request, t[0]):
|
||||
scope = f"{p.id}_{p.membership.club.id}"
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
if 'openid' in scopes:
|
||||
# We grant openid scope if user is active
|
||||
if 'openid' in scopes and req.user.is_active:
|
||||
valid_scopes.add('openid')
|
||||
|
||||
# Always give one scope to generate token
|
||||
if not valid_scopes:
|
||||
valid_scopes.add('0_0')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
||||
@@ -21,6 +21,7 @@ class OAuth2TestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(
|
||||
username="toto",
|
||||
password="toto1234",
|
||||
)
|
||||
self.application = Application.objects.create(
|
||||
name="Test",
|
||||
@@ -92,3 +93,40 @@ class OAuth2TestCase(TestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(self.application, resp.context['scopes'])
|
||||
self.assertIn('1_1', resp.context['scopes'][self.application]) # Now the user has this permission
|
||||
|
||||
def test_oidc(self):
|
||||
"""
|
||||
Ensure OIDC work
|
||||
"""
|
||||
# Create access token that has access to our own user detail
|
||||
token = AccessToken.objects.create(
|
||||
user=self.user,
|
||||
application=self.application,
|
||||
scope="openid",
|
||||
token=get_random_string(64),
|
||||
expires=timezone.now() + timedelta(days=365),
|
||||
)
|
||||
|
||||
# No access without token
|
||||
resp = self.client.get('/o/userinfo/') # userinfo endpoint
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
# Valid token
|
||||
resp = self.client.get('/o/userinfo/', **{'Authorization': f'Bearer {token.token}'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Create membership to test api
|
||||
NoteUser.objects.create(user=self.user)
|
||||
membership = Membership.objects.create(user=self.user, club_id=1)
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
|
||||
membership.save()
|
||||
|
||||
# Token can always be use to see yourself
|
||||
resp = self.client.get('/api/me/',
|
||||
**{'Authorization': f'Bearer {token.token}'})
|
||||
|
||||
# Token is not granted to see other api
|
||||
resp = self.client.get(f'/api/members/profile/{self.user.profile.pk}/',
|
||||
**{'Authorization': f'Bearer {token.token}'})
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
444
apps/permission/tests/test_oauth2_flow.py
Normal file
444
apps/permission/tests/test_oauth2_flow.py
Normal file
@@ -0,0 +1,444 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.test import TestCase
|
||||
from member.models import Membership, Club
|
||||
from note.models import NoteUser
|
||||
from oauth2_provider.models import Application, AccessToken, Grant
|
||||
|
||||
from ..models import Role, Permission
|
||||
|
||||
|
||||
class OAuth2FlowTestCase(TestCase):
|
||||
fixtures = ('initial', )
|
||||
|
||||
def setUp(self):
|
||||
self.user_password = "toto1234"
|
||||
hasher = PBKDF2PasswordHasher()
|
||||
|
||||
self.user = User.objects.create(
|
||||
username="toto",
|
||||
password=hasher.encode(self.user_password, hasher.salt()),
|
||||
)
|
||||
|
||||
NoteUser.objects.create(user=self.user)
|
||||
membership = Membership.objects.create(user=self.user, club_id=1)
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
|
||||
membership.save()
|
||||
|
||||
bde = Club.objects.get(name="BDE")
|
||||
view_user_perm = Permission.objects.get(pk=1) # View own user detail
|
||||
|
||||
self.base_scope = f'{view_user_perm.pk}_{bde.pk}'
|
||||
|
||||
def test_oauth2_authorization_code_flow(self):
|
||||
"""
|
||||
Ensure OAuth2 Authorization Code Flow work
|
||||
"""
|
||||
|
||||
app = Application.objects.create(
|
||||
name="Test Authorization Code",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
redirect_uris='http://127.0.0.1:8000/noexist/callback',
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
)
|
||||
|
||||
credential = base64.b64encode(f'{app.client_id}:{app.client_secret}'.encode('utf-8')).decode()
|
||||
|
||||
############################
|
||||
# Minimal RFC6749 requests #
|
||||
############################
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={"response_type": "code", # REQUIRED
|
||||
"client_id": app.client_id}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'})
|
||||
|
||||
# Get user authorization
|
||||
|
||||
##################################################################################
|
||||
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": "0_0",
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "code",
|
||||
"client_id": app.client_id,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
keys = resp.request['QUERY_STRING'].split("&")
|
||||
for key in keys:
|
||||
if len(key.split('code=')) == 2:
|
||||
code = key.split('code=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
grant = Grant.objects.get(code=code)
|
||||
self.assertEqual(grant.scope, '0_0')
|
||||
|
||||
# Now we can ask an Access Token
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": 'authorization_code', # REQUIRED
|
||||
"code": code}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'})
|
||||
|
||||
# We should have refresh token
|
||||
self.assertEqual('refresh_token' in resp.json(), True)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token do nothing, it should be have the useless scope
|
||||
self.assertEqual(token.scope, '0_0')
|
||||
|
||||
# Logout user
|
||||
self.client.logout()
|
||||
|
||||
#############################################
|
||||
# Maximal RFC6749 + RFC7636 (PKCE) requests #
|
||||
#############################################
|
||||
|
||||
state = get_random_string(32)
|
||||
|
||||
# PKCE
|
||||
code_verifier = get_random_string(100) # 43-128 characters [A-Z,a-z,0-9,"-",".","_","~"]
|
||||
code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '')
|
||||
cc_method = "S256"
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={"response_type": "code", # REQUIRED
|
||||
"code_challenge": code_challenge, # PKCE REQUIRED
|
||||
"code_challenge_method": cc_method, # PKCE REQUIRED
|
||||
"client_id": app.client_id, # REQUIRED
|
||||
"redirect_uri": app.redirect_uris, # OPTIONAL
|
||||
"scope": self.base_scope, # OPTIONAL
|
||||
"state": state}, # RECOMMENDED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'})
|
||||
|
||||
# Get user authorization
|
||||
##################################################################################
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": self.base_scope,
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "code",
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": cc_method,
|
||||
"client_id": app.client_id,
|
||||
"state": state,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
keys = resp.request['QUERY_STRING'].split("&")
|
||||
for key in keys:
|
||||
if len(key.split('code=')) == 2:
|
||||
code = key.split('code=')[1]
|
||||
if len(key.split('state=')) == 2:
|
||||
resp_state = key.split('state=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
grant = Grant.objects.get(code=code)
|
||||
self.assertEqual(grant.scope, self.base_scope)
|
||||
self.assertEqual(state, resp_state)
|
||||
|
||||
# Now we can ask an Access Token
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": 'authorization_code', # REQUIRED
|
||||
"code": code, # REQUIRED
|
||||
"code_verifier": code_verifier, # PKCE REQUIRED
|
||||
"redirect_uri": app.redirect_uris}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'})
|
||||
|
||||
# We should have refresh token
|
||||
self.assertEqual('refresh_token' in resp.json(), True)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token can have access, it shouldn't have the useless scope
|
||||
self.assertEqual(token.scope, self.base_scope)
|
||||
|
||||
def test_oauth2_implicit_flow(self):
|
||||
"""
|
||||
Ensure OAuth2 Implicit Flow work
|
||||
"""
|
||||
app = Application.objects.create(
|
||||
name="Test Implicit Flow",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_IMPLICIT,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
redirect_uris='http://127.0.0.1:8000/noexist/callback/',
|
||||
)
|
||||
|
||||
############################
|
||||
# Minimal RFC6749 requests #
|
||||
############################
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={'response_type': 'token', # REQUIRED
|
||||
'client_id': app.client_id}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
# Get user authorization
|
||||
##################################################################################
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": '0_0',
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "token",
|
||||
"client_id": app.client_id,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
url = resp.redirect_chain[0][0]
|
||||
keys = url.split('#')[1]
|
||||
refresh_token = ''
|
||||
|
||||
for couple in keys.split('&'):
|
||||
if couple.split('=')[0] == 'access_token':
|
||||
token = couple.split('=')[1]
|
||||
if couple.split('=')[0] == 'refresh_token':
|
||||
refresh_token = couple.split('=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
self.assertEqual(refresh_token, '')
|
||||
|
||||
access_token = AccessToken.objects.get(token=token)
|
||||
|
||||
# Token do nothing, it should be have the useless scope
|
||||
self.assertEqual(access_token.scope, '0_0')
|
||||
|
||||
# Logout user
|
||||
self.client.logout()
|
||||
|
||||
############################
|
||||
# Maximal RFC6749 requests #
|
||||
############################
|
||||
|
||||
state = get_random_string(32)
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={'response_type': 'token', # REQUIRED
|
||||
'client_id': app.client_id, # REQUIRED
|
||||
'redirect_uri': app.redirect_uris, # OPTIONAL
|
||||
'scope': self.base_scope, # OPTIONAL
|
||||
'state': state}, # RECOMMENDED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
# Get user authorization
|
||||
##################################################################################
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": self.base_scope,
|
||||
"state": state,
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "token",
|
||||
"client_id": app.client_id,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
url = resp.redirect_chain[0][0]
|
||||
keys = url.split('#')[1]
|
||||
refresh_token = ''
|
||||
|
||||
for couple in keys.split('&'):
|
||||
if couple.split('=')[0] == 'access_token':
|
||||
token = couple.split('=')[1]
|
||||
if couple.split('=')[0] == 'refresh_token':
|
||||
refresh_token = couple.split('=')[1]
|
||||
if couple.split('=')[0] == 'state':
|
||||
resp_state = couple.split('=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
self.assertEqual(refresh_token, '')
|
||||
|
||||
access_token = AccessToken.objects.get(token=token)
|
||||
|
||||
# Token can have access, it shouldn't have the useless scope
|
||||
self.assertEqual(access_token.scope, self.base_scope)
|
||||
|
||||
self.assertEqual(state, resp_state)
|
||||
|
||||
def test_oauth2_resource_owner_password_credentials_flow(self):
|
||||
"""
|
||||
Ensure OAuth2 Resource Owner Password Credentials Flow work
|
||||
"""
|
||||
app = Application.objects.create(
|
||||
name="Test ROPB",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_PASSWORD,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
)
|
||||
|
||||
credential = base64.b64encode(f'{app.client_id}:{app.client_secret}'.encode('utf-8')).decode()
|
||||
|
||||
# No token without real password
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "password", # REQUIRED
|
||||
"username": self.user, # REQUIRED
|
||||
"password": "password"}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"Http_Authorization": f'Basic {credential}'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "password", # REQUIRED
|
||||
"username": self.user, # REQUIRED
|
||||
"password": self.user_password}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
access_token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
self.assertEqual('refresh_token' in resp.json(), True)
|
||||
|
||||
self.assertEqual(access_token.scope, '0_0') # token do nothing
|
||||
|
||||
# RFC6749 4.3.2 allows use of scope in ROPB token access request
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "password", # REQUIRED
|
||||
"username": self.user, # REQUIRED
|
||||
"password": self.user_password, # REQUIRED
|
||||
"scope": self.base_scope}, # OPTIONAL
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'}
|
||||
)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
self.assertEqual(token.scope, self.base_scope) # token do nothing more than base_scope
|
||||
|
||||
def test_oauth2_client_credentials(self):
|
||||
"""
|
||||
Ensure OAuth2 Client Credentials work
|
||||
"""
|
||||
app = Application.objects.create(
|
||||
name="Test client_credentials",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
)
|
||||
|
||||
# No token without credential
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "client_credentials"}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
# Access with credential
|
||||
credential = base64.b64encode(f'{app.client_id}:{app.client_secret}'.encode('utf-8')).decode()
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "client_credentials"}, # REQUIRED
|
||||
**{'HTTP_Authorization': f'Basic {credential}',
|
||||
"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token do nothing, it should be have the useless scope
|
||||
self.assertEqual(token.scope, '0_0')
|
||||
|
||||
# RFC6749 4.4.2 allows use of scope in client credential flow
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "client_credentials", # REQUIRED
|
||||
"scope": self.base_scope}, # OPTIONAL
|
||||
**{'http_Authorization': f'Basic {credential}',
|
||||
"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token can have access, it shouldn't have the useless scope
|
||||
self.assertEqual(token.scope, self.base_scope)
|
||||
@@ -84,7 +84,7 @@ Le script *generate_wrapped* fonctionne de la manière suivante :
|
||||
wrapped·s va/vont être généré·s
|
||||
ou regénéré·s.
|
||||
* ``global_data`` : le script génére ensuite des statistiques globales qui concernent pas qu'une seule
|
||||
note (nombre de soirée, classement, etc).
|
||||
note (nombre de soirée, classement, etc).
|
||||
* ``unique_data`` : le script génére les statitiques uniques à chaque note, et rajoute des données
|
||||
globales si nécessaire, pour chaque note on souhaite avoir un json avec toutes les données qui
|
||||
seront dans le wrapped.
|
||||
|
||||
@@ -18,11 +18,21 @@ note. De cette façon, chaque application peut authentifier ses utilisateur⋅ri
|
||||
et récupérer leurs adhésions, leur nom de note afin d'éventuellement faire des transferts
|
||||
via l'API.
|
||||
|
||||
Deux protocoles d'authentification sont implémentées :
|
||||
Trois protocoles d'authentification sont implémentées :
|
||||
|
||||
* `CAS <cas>`_
|
||||
* `OAuth2 <oauth2>`_
|
||||
* Open ID Connect
|
||||
|
||||
À ce jour, il n'y a pas encore d'exemple d'utilisation d'application qui utilise ce
|
||||
mécanisme, mais on peut imaginer par exemple que la Mediatek ou l'AMAP implémentent
|
||||
ces protocoles pour récupérer leurs adhérent⋅es.
|
||||
À ce jour, ce mécanisme est notamment utilisé par :
|
||||
* Le `serveur photo <https://photos.crans.org>`_
|
||||
* L'`imprimante <https://helloworld.crans.org>`_ du `Cr@ns <https://crans.org>`_
|
||||
* Le serveur `Matrix <https://element.crans.org>`_ du `Cr@ns <https://crans.org>`_
|
||||
* La `base de donnée de la Mediatek <https://med.crans.org>`_
|
||||
* Le site du `K-WEI <https://kwei.crans.org>`_
|
||||
|
||||
Et dans un futur plus ou moins proche :
|
||||
* Le site pour loger les admissibles pendant les oraux (cf. `ici <https://gitlab.crans.org/bde/la25>`_)
|
||||
* L'application mobile de la note
|
||||
* Le site pour les commandes Terre à Terre (cf. `là <https://gitlab.crans.org/tat/blog>`_)
|
||||
* Le futur wiki...
|
||||
|
||||
@@ -47,7 +47,6 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions
|
||||
'OIDC_ENABLED': True,
|
||||
'OIDC_RSA_PRIVATE_KEY':
|
||||
os.getenv('OIDC_RSA_PRIVATE_KEY', '/var/secrets/oidc.key'),
|
||||
'SCOPES': { 'openid': "OpenID Connect scope" },
|
||||
}
|
||||
|
||||
Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
|
||||
@@ -99,7 +98,7 @@ du format renvoyé.
|
||||
|
||||
.. warning::
|
||||
|
||||
Un petit mot sur les scopes : tel qu'implémenté, une scope est une permission unitaire
|
||||
Un petit mot sur les scopes : tel qu'implémenté, un scope est une permission unitaire
|
||||
(telle que décrite dans le modèle ``Permission``) associée à un club. Ainsi, un jeton
|
||||
a accès à une scope si et seulement si læ propriétaire du jeton dispose d'une adhésion
|
||||
courante dans le club lié à la scope qui lui octroie cette permission.
|
||||
@@ -113,6 +112,9 @@ du format renvoyé.
|
||||
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
|
||||
jetons.
|
||||
|
||||
Deux scopes sont un peu particulier, le scope "0_0" qui ne donne aucune permission
|
||||
et le scope "openid" pour l'OIDC.
|
||||
|
||||
.. danger::
|
||||
|
||||
Demander des scopes n'implique pas de les avoir.
|
||||
@@ -134,6 +136,11 @@ du format renvoyé.
|
||||
uniquement dans le cas où l'utilisateur⋅rice connecté⋅e
|
||||
possède la permission problématique.
|
||||
|
||||
Dans le cas extrême ou aucun scope demandé n'est obtenus, vous
|
||||
obtiendriez le scope "0_0" qui ne permet l'accès à rien.
|
||||
Cela permet de générer un token pour toute les requêtes valides.
|
||||
|
||||
|
||||
Avec Django-allauth
|
||||
###################
|
||||
|
||||
@@ -142,6 +149,10 @@ le module pré-configuré disponible ici :
|
||||
`<https://gitlab.crans.org/bde/allauth-note-kfet>`_. Pour l'installer, vous
|
||||
pouvez simplement faire :
|
||||
|
||||
.. warning::
|
||||
À cette heure (11/2025), ce paquet est déprécié et il est plutôt conseillé de créer
|
||||
sa propre application.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ pip3 install git+https://gitlab.crans.org/bde/allauth-note-kfet.git
|
||||
@@ -195,6 +206,20 @@ récupérés. Les autres données sont stockées mais inutilisées.
|
||||
Application personnalisée
|
||||
#########################
|
||||
|
||||
.. note::
|
||||
|
||||
Tout les flow (c'est-à-dire les différentes suites de requête possible pour obtenir
|
||||
un token d'accès) de l'OAuth2 sont reproduits dans les
|
||||
`tests <https://gitlab.crans.org/bde/nk20/-/tree/main/apps/permission/tests/test_oauth2_flow.py>`_
|
||||
de l'application permission de la Note. L'OIDC n'étant qu'une extension du protocole
|
||||
OAuth2 vous pouvez facilement reproduire les requêtes en vous inspirant de
|
||||
l'Authorization Code de OAuth2.
|
||||
|
||||
.. danger::
|
||||
|
||||
Pour des raisons de rétrocompatibilité, PKCE (Proof Key for Code Exchange) n'est pas requis,
|
||||
son utilisation est néanmoins très vivement conseillé.
|
||||
|
||||
Ce modèle vous permet de créer vos propres applications à interfacer avec la Note Kfet.
|
||||
|
||||
Commencez par créer une application : `<https://note.crans.org/o/applications/register>`_.
|
||||
@@ -223,6 +248,8 @@ c'est sur cette page qu'il faut rediriger les utilisateur⋅rices. Il faut mettr
|
||||
autorisée par l'application. À des fins de test, peut être `<http://localhost/>`_.
|
||||
* ``state`` : optionnel, peut être utilisé pour permettre au client de détecter des requêtes
|
||||
provenant d'autres sites.
|
||||
* ``code_challenge``: PKCE, le hash d'une chaine d'entre 43 et 128 caractères.
|
||||
* ``code_challenge_method``: PKCE, ``S256`` si le hasher est sha256.
|
||||
|
||||
Sur cette page, les permissions demandées seront listées, et l'utilisateur⋅rice aura le
|
||||
choix d'accepter ou non. Dans les deux cas, l'utilisateur⋅rice sera redirigée vers
|
||||
@@ -283,4 +310,4 @@ de rafraichissement à usage unique. Il suffit pour cela de refaire une requête
|
||||
Le serveur vous fournira alors une nouvelle paire de jetons, comme précédemment.
|
||||
À noter qu'un jeton de rafraîchissement est à usage unique.
|
||||
|
||||
N'hésitez pas à vous renseigner sur OAuth2 pour plus d'informations.
|
||||
N'hésitez pas à vous renseigner sur `OAuth2 <https://www.rfc-editor.org/rfc/rfc6749.html>`_ ou sur le protocole `OIDC <https://openid.net/specs/openid-connect-core-1_0.html>`_ pour plus d'informations.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -273,9 +273,9 @@ OAUTH2_PROVIDER = {
|
||||
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
||||
'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0)
|
||||
'OIDC_ENABLED': True,
|
||||
'OIDC_RP_INITIATED_LOGOUT_ENABLED': False,
|
||||
'OIDC_RSA_PRIVATE_KEY':
|
||||
os.getenv('OIDC_RSA_PRIVATE_KEY', 'CHANGE_ME_IN_ENV_SETTINGS').replace('\\n', '\n'), # for multilines
|
||||
'SCOPES': { 'openid': "OpenID Connect scope" },
|
||||
}
|
||||
|
||||
# Take control on how widget templates are sourced
|
||||
|
||||
@@ -1,220 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="livetype" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="127.5px" height="41px" viewBox="0 0 127.5 41" enable-background="new 0 0 127.5 41" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#A6A6A6" d="M116.9740829,0H9.5381069C9.1714067,0,8.8090963,0,8.4433765,0.001953
|
||||
C8.1372271,0.003906,7.8335166,0.009766,7.5244284,0.014648C6.8589067,0.03125,6.1850767,0.072266,5.5205269,0.191406
|
||||
C4.8510966,0.308594,4.2290268,0.508789,3.6196468,0.818359C3.0210168,1.125,2.4741282,1.52344,2.0009968,1.99707
|
||||
c-0.47852,0.4736301-0.875,1.0224601-1.17822,1.6210901c-0.311039,0.6084001-0.508305,1.2334001-0.6250041,1.9033201
|
||||
c-0.120606,0.6621099-0.162109,1.3320398-0.179199,2.0019598C0.0092958,7.83008,0.0083198,8.1377001,0.0034284,8.4443398
|
||||
c0,0.3622999,0,0.7255802,0,1.0917902v20.928669c0,0.3691998,0,0.7304993,0,1.0937996
|
||||
c0.0048914,0.3105011,0.0058674,0.6112995,0.0151454,0.9218998c0.01709,0.669899,0.058593,1.3398018,0.179199,2.0018997
|
||||
c0.116699,0.6699028,0.313965,1.2979012,0.6250041,1.9043007c0.30322,0.5956993,0.6997,1.1445999,1.17822,1.6142998
|
||||
c0.4731314,0.4775009,1.02002,0.875,1.61865,1.1786995c0.60938,0.3125,1.2314498,0.5098,1.9008801,0.6308022
|
||||
c0.6645498,0.1191978,1.3383799,0.1582985,2.0039015,0.1767998c0.3090882,0.0067978,0.6127987,0.0107002,0.9189482,0.0107002
|
||||
C8.8090963,40,9.1714067,40,9.5381069,40h107.4359741c0.3593979,0,0.7246017,0,1.0839996-0.0019989
|
||||
c0.3047028,0,0.6172028-0.0039024,0.9218979-0.0107002c0.6699066-0.0185013,1.3418045-0.0576019,2-0.1767998
|
||||
c0.6699066-0.1210022,1.2929001-0.3183022,1.9082031-0.6308022c0.5976028-0.3036995,1.1445007-0.7011986,1.6172028-1.1786995
|
||||
c0.4764938-0.4696999,0.8730011-1.0186005,1.1815948-1.6142998c0.3066025-0.6063995,0.5059052-1.2343979,0.6191025-1.9043007
|
||||
c0.1231003-0.6620979,0.1621017-1.3320007,0.1856003-2.0018997c0.0038986-0.3106003,0.0038986-0.6113987,0.0038986-0.9218998
|
||||
c0.0078049-0.3633003,0.0078049-0.7245998,0.0078049-1.0937996V9.53613c0-0.36621,0-0.7294903-0.0078049-1.0917902
|
||||
c0-0.3066397,0-0.6142597-0.0038986-0.9208999c-0.0234985-0.66992-0.0625-1.3398499-0.1856003-2.0019598
|
||||
c-0.1131973-0.66992-0.3125-1.29492-0.6191025-1.9033201c-0.3085938-0.59863-0.705101-1.14746-1.1815948-1.6210901
|
||||
c-0.472702-0.47363-1.0195999-0.87207-1.6172028-1.1787109c-0.615303-0.30957-1.2382965-0.509765-1.9082031-0.626953
|
||||
c-0.6581955-0.11914-1.3300934-0.160156-2-0.176758c-0.3046951-0.004882-0.6171951-0.010742-0.9218979-0.012695
|
||||
C117.6986847,0,117.3334808,0,116.9740829,0L116.9740829,0z"/>
|
||||
<path d="M8.4482479,39.125c-0.3046713,0-0.6020412-0.0038986-0.9042811-0.0107002
|
||||
c-0.5590801-0.0157013-1.2221699-0.0468979-1.8691401-0.1631012
|
||||
c-0.6103497-0.1103973-1.1528397-0.2901001-1.6567397-0.5478973c-0.5214901-0.2646027-0.9902401-0.6063995-1.39697-1.0166016
|
||||
C2.2070467,36.9804993,1.8667167,36.5136986,1.6006068,35.9902c-0.25928-0.5047989-0.43653-1.0467987-0.5429784-1.6571999
|
||||
c-0.1220616-0.672802-0.1533116-1.3554993-0.1665-1.875c-0.0063416-0.2108994-0.0146416-0.9130993-0.0146416-0.9130993
|
||||
V8.4443398c0,0,0.00879-0.6914096,0.0146416-0.8945398c0.0131884-0.5239201,0.0444384-1.2060499,0.16552-1.8720698
|
||||
c0.1069484-0.61377,0.2841799-1.1552701,0.5434684-1.6621003C1.8657284,3.49121,2.2065668,3.02197,2.6152482,2.6176801
|
||||
C3.0288267,2.2036099,3.4995267,1.86084,4.0175967,1.59521C4.5312667,1.33447,5.0727768,1.15625,5.6709166,1.05127
|
||||
c0.6733317-0.120606,1.3559604-0.150879,1.8754902-0.164063L8.4487467,0.875h109.6044388l0.9131012,0.012695
|
||||
c0.5126953,0.012696,1.1952972,0.042969,1.8583984,0.162595c0.6025009,0.1054701,1.1474991,0.28467,1.6708984,0.54785
|
||||
c0.5127029,0.2627,0.982399,0.6054699,1.3916016,1.01563c0.4092026,0.40625,0.7518997,0.8779299,1.0233994,1.4043002
|
||||
c0.2578049,0.51123,0.4336014,1.0527296,0.535202,1.6489196c0.1161957,0.6308603,0.152298,1.27881,0.1737976,1.8872104
|
||||
c0.0028992,0.2831998,0.0028992,0.5873995,0.0028992,0.8901396c0.0079041,0.375,0.0079041,0.7319298,0.0079041,1.0917902
|
||||
v20.928669c0,0.3633003,0,0.7178001-0.0079041,1.075201c0,0.3251991,0,0.6231003-0.0038986,0.9296989
|
||||
c-0.0205002,0.5889015-0.0566025,1.2364006-0.1708984,1.8535004
|
||||
c-0.1035004,0.6133003-0.2792969,1.1553001-0.5400009,1.6699982
|
||||
c-0.2695007,0.5195007-0.6122971,0.9892006-1.0156021,1.3857002c-0.4131012,0.4180031-0.881897,0.7588005-1.3993988,1.0225029
|
||||
c-0.5186005,0.2635994-1.0478973,0.4384003-1.6679993,0.5497971
|
||||
c-0.6406021,0.1162033-1.3037033,0.1473999-1.8692017,0.1631012
|
||||
c-0.2929001,0.0068016-0.5996017,0.0107002-0.8973999,0.0107002l-1.0839996,0.0019989L8.4482479,39.125z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<g id="XMLID_20_">
|
||||
<g id="XMLID_22_">
|
||||
<g id="XMLID_23_">
|
||||
<path id="XMLID_25_" fill="#FFFFFF" d="M24.7752247,20.3006763c-0.0250111-2.7508278,2.2523365-4.0894947,2.3565578-4.1520615
|
||||
c-1.2895851-1.880518-3.2889214-2.137701-3.9911613-2.1576748c-1.679245-0.1762638-3.307188,1.0048323-4.1629009,1.0048323
|
||||
c-0.8722668,0-2.1897697-0.9873343-3.6085024-0.9581413c-1.8263159,0.0283384-3.5356064,1.0857515-4.4729214,2.7278662
|
||||
c-1.9339523,3.3484154-0.4914045,8.2694664,1.3612013,10.976078c0.9269009,1.3253498,2.0101776,2.8057976,3.4276295,2.7533016
|
||||
c1.387064-0.0575314,1.9051018-0.8844776,3.5793953-0.8844776c1.6587582,0,2.1447868,0.8844776,3.5910034,0.8511028
|
||||
c1.4883842-0.0241566,2.4261265-1.3312416,3.3205109-2.6691399c1.0711498-1.5195389,1.5012684-3.0158634,1.518425-3.092514
|
||||
C27.6598072,24.6881542,24.8035622,23.598732,24.7752247,20.3006763z"/>
|
||||
<path id="XMLID_24_" fill="#FFFFFF" d="M22.043602,12.2108879c0.7456875-0.933217,1.2562122-2.2023182,1.1145172-3.4906235
|
||||
c-1.0798607,0.0478859-2.4303074,0.7464542-3.2075748,1.6596127
|
||||
c-0.6877289,0.8039846-1.3024769,2.1223364-1.1437111,3.3613911
|
||||
C20.0196838,13.8313208,21.2650547,13.1294231,22.043602,12.2108879z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="XMLID_1_">
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M37.5542183,8.7348633c1.1660156,0,1.9677734,0.8061523,1.9677734,1.9887695
|
||||
c0,1.1660156-0.8261719,1.9682617-1.9970703,1.9682617H36.144062v2.0097656h-0.9267578V8.7348633H37.5542183z
|
||||
M36.144062,11.8774414h1.1660156c0.7978516,0,1.265625-0.4135742,1.265625-1.1538086
|
||||
c0-0.7568359-0.4511719-1.1704102-1.265625-1.1704102H36.144062V11.8774414z"/>
|
||||
<path fill="#FFFFFF" d="M40.7358589,10.1987305h0.8554688v0.6904297h0.0664062
|
||||
c0.1279297-0.4423828,0.6279297-0.7651367,1.2158203-0.7651367c0.1318359,0,0.3017578,0.012207,0.3964844,0.0371094v0.8769531
|
||||
c-0.0742188-0.0249023-0.3388672-0.0537109-0.4960938-0.0537109c-0.6738281,0-1.1494141,0.4257812-1.1494141,1.0585938
|
||||
v2.6586914h-0.8886719V10.1987305z"/>
|
||||
<path fill="#FFFFFF" d="M47.9438667,13.4858398c-0.2021484,0.8066406-0.921875,1.3027344-1.9511719,1.3027344
|
||||
c-1.2900391,0-2.0800781-0.8847656-2.0800781-2.3242188c0-1.4389648,0.8066406-2.3525391,2.0761719-2.3525391
|
||||
c1.2529297,0,2.0087891,0.855957,2.0087891,2.2700195v0.3100586h-3.1796875v0.0498047
|
||||
c0.0292969,0.7895508,0.4882812,1.2900391,1.1992188,1.2900391c0.5380859,0,0.90625-0.1943359,1.0712891-0.5458984H47.9438667z
|
||||
M44.8178902,12.034668h2.2744141c-0.0205078-0.7070312-0.4501953-1.1665039-1.1083984-1.1665039
|
||||
C45.3266792,10.8681641,44.8676949,11.331543,44.8178902,12.034668z M45.4546089,9.4458008l1.0380859-1.4223633h1.0419922
|
||||
l-1.1621094,1.4223633H45.4546089z"/>
|
||||
<path fill="#FFFFFF" d="M52.1391792,11.6704102c-0.1035156-0.4379883-0.4677734-0.7646484-1.0634766-0.7646484
|
||||
c-0.7441406,0-1.1992188,0.5703125-1.1992188,1.5297852c0,0.9760742,0.4589844,1.559082,1.1992188,1.559082
|
||||
c0.5625,0,0.9472656-0.2563477,1.0634766-0.7402344h0.8642578c-0.1162109,0.9057617-0.8105469,1.5341797-1.9228516,1.5341797
|
||||
c-1.3115234,0-2.1132812-0.8847656-2.1132812-2.3530273c0-1.4428711,0.7978516-2.3237305,2.1083984-2.3237305
|
||||
c1.1289062,0,1.8115234,0.6572266,1.9277344,1.5585938H52.1391792z"/>
|
||||
<path fill="#FFFFFF" d="M53.9184761,12.4482422c0-1.4516602,0.8105469-2.3364258,2.125-2.3364258
|
||||
c1.3115234,0,2.1220703,0.8847656,2.1220703,2.3364258c0,1.4594727-0.8066406,2.340332-2.1220703,2.340332
|
||||
C54.7251167,14.7885742,53.9184761,13.9077148,53.9184761,12.4482422z M57.2514839,12.4482422
|
||||
c0-0.9760742-0.4384766-1.546875-1.2080078-1.546875c-0.7724609,0-1.2070312,0.5708008-1.2070312,1.546875
|
||||
c0,0.9838867,0.4345703,1.550293,1.2070312,1.550293C56.8130074,13.9985352,57.2514839,13.4282227,57.2514839,12.4482422z"/>
|
||||
<path fill="#FFFFFF" d="M59.3579292,10.1987305h0.8554688v0.7236328h0.0664062
|
||||
c0.1982422-0.5087891,0.6533203-0.8105469,1.2529297-0.8105469c0.6162109,0,1.0419922,0.3183594,1.2402344,0.8105469h0.0703125
|
||||
c0.2275391-0.4921875,0.7441406-0.8105469,1.3691444-0.8105469c0.9086914,0,1.4379883,0.5498047,1.4379883,1.4882812v3.1015625
|
||||
h-0.8881836v-2.8696289c0-0.6079102-0.2900391-0.9057617-0.8730507-0.9057617
|
||||
c-0.5742188,0-0.9501953,0.4135742-0.9501953,0.9428711v2.8325195H62.065937v-2.956543
|
||||
c0-0.5087891-0.3388672-0.8188477-0.8681641-0.8188477c-0.5419922,0-0.9511719,0.4423828-0.9511719,1.0214844v2.7539062
|
||||
h-0.8886719V10.1987305z"/>
|
||||
<path fill="#FFFFFF" d="M67.024437,10.1987305h0.8554688v0.7236328h0.0664062
|
||||
c0.1982422-0.5087891,0.6533203-0.8105469,1.2529297-0.8105469c0.6162109,0,1.0419922,0.3183594,1.2402344,0.8105469h0.0703125
|
||||
c0.2275391-0.4921875,0.7441406-0.8105469,1.3691406-0.8105469c0.9091797,0,1.4384766,0.5498047,1.4384766,1.4882812v3.1015625
|
||||
h-0.8886719v-2.8696289c0-0.6079102-0.2900391-0.9057617-0.8730469-0.9057617
|
||||
c-0.5742188,0-0.9501953,0.4135742-0.9501953,0.9428711v2.8325195h-0.8730469v-2.956543
|
||||
c0-0.5087891-0.3388672-0.8188477-0.8681641-0.8188477c-0.5419922,0-0.9511719,0.4423828-0.9511719,1.0214844v2.7539062
|
||||
H67.024437V10.1987305z"/>
|
||||
<path fill="#FFFFFF" d="M74.4345932,13.4282227c0-0.8105469,0.6035156-1.277832,1.6748047-1.3442383l1.2197266-0.0703125V11.625
|
||||
c0-0.4755859-0.3144531-0.7441406-0.921875-0.7441406c-0.4960938,0-0.8398438,0.1821289-0.9384766,0.5004883h-0.8603516
|
||||
c0.0908203-0.7734375,0.8183594-1.2695312,1.8398438-1.2695312c1.1289062,0,1.765625,0.5620117,1.765625,1.5131836v3.0766602
|
||||
h-0.8554688v-0.6328125h-0.0703125c-0.2685547,0.4506836-0.7607422,0.7070312-1.3525391,0.7070312
|
||||
C75.0674057,14.7758789,74.4345932,14.2509766,74.4345932,13.4282227z M77.3291245,13.043457v-0.3764648l-1.0996094,0.0703125
|
||||
c-0.6201172,0.0415039-0.9013672,0.2524414-0.9013672,0.6494141c0,0.4052734,0.3515625,0.6411133,0.8349609,0.6411133
|
||||
C76.8330307,14.027832,77.3291245,13.6015625,77.3291245,13.043457z"/>
|
||||
<path fill="#FFFFFF" d="M79.6054916,10.1987305h0.8554688v0.715332h0.0664062
|
||||
c0.21875-0.5004883,0.6660156-0.8022461,1.34375-0.8022461c1.0048828,0,1.5585938,0.6035156,1.5585938,1.6748047v2.9150391
|
||||
h-0.8886719v-2.6918945c0-0.7236328-0.3144531-1.0834961-0.9716797-1.0834961s-1.0751953,0.4384766-1.0751953,1.1411133
|
||||
v2.6342773h-0.8886719V10.1987305z"/>
|
||||
<path fill="#FFFFFF" d="M84.5810776,12.4482422c0-1.4228516,0.7314453-2.3242188,1.8691406-2.3242188
|
||||
c0.6162109,0,1.1367188,0.293457,1.3808594,0.7900391h0.0664062V8.440918h0.8886719v6.2607422h-0.8515625v-0.7114258h-0.0703125
|
||||
c-0.2685547,0.4921875-0.7939453,0.7856445-1.4140625,0.7856445
|
||||
C85.3047104,14.7758789,84.5810776,13.8745117,84.5810776,12.4482422z M85.4990463,12.4482422
|
||||
c0,0.9550781,0.4501953,1.5297852,1.203125,1.5297852c0.7490234,0,1.2119141-0.5830078,1.2119141-1.5258789
|
||||
c0-0.9384766-0.4677734-1.5297852-1.2119141-1.5297852C85.9541245,10.9223633,85.4990463,11.5009766,85.4990463,12.4482422z"/>
|
||||
<path fill="#FFFFFF" d="M94.0517807,13.4858398c-0.2021484,0.8066406-0.921875,1.3027344-1.9511719,1.3027344
|
||||
c-1.2900391,0-2.0800781-0.8847656-2.0800781-2.3242188c0-1.4389648,0.8066406-2.3525391,2.0761719-2.3525391
|
||||
c1.2529297,0,2.0087891,0.855957,2.0087891,2.2700195v0.3100586h-3.1796875v0.0498047
|
||||
c0.0292969,0.7895508,0.4882812,1.2900391,1.1992188,1.2900391c0.5380859,0,0.90625-0.1943359,1.0712891-0.5458984H94.0517807z
|
||||
M90.9258041,12.034668h2.2744141c-0.0205078-0.7070312-0.4501953-1.1665039-1.1083984-1.1665039
|
||||
C91.4345932,10.8681641,90.9756088,11.331543,90.9258041,12.034668z"/>
|
||||
<path fill="#FFFFFF" d="M95.2998276,10.1987305h0.8554688v0.6904297h0.0664062
|
||||
c0.1279297-0.4423828,0.6279297-0.7651367,1.2158203-0.7651367c0.1318359,0,0.3017578,0.012207,0.3964844,0.0371094v0.8769531
|
||||
c-0.0742188-0.0249023-0.3388672-0.0537109-0.4960938-0.0537109c-0.6738281,0-1.1494141,0.4257812-1.1494141,1.0585938
|
||||
v2.6586914h-0.8886719V10.1987305z"/>
|
||||
<path fill="#FFFFFF" d="M102.9043198,10.1118164c1.0126953,0,1.6748047,0.4711914,1.7617188,1.2651367h-0.8525391
|
||||
c-0.0820312-0.3305664-0.4052734-0.5415039-0.9091797-0.5415039c-0.4960938,0-0.8730469,0.2353516-0.8730469,0.5869141
|
||||
c0,0.269043,0.2275391,0.4384766,0.7158203,0.550293l0.7480469,0.1733398
|
||||
c0.8564453,0.1987305,1.2578125,0.5668945,1.2578125,1.2285156c0,0.8476562-0.7900391,1.4140625-1.8652344,1.4140625
|
||||
c-1.0712891,0-1.7695312-0.4838867-1.8486328-1.2817383h0.8896484c0.1113281,0.347168,0.4423828,0.5620117,0.9794922,0.5620117
|
||||
c0.5537109,0,0.9472656-0.2480469,0.9472656-0.6079102c0-0.2685547-0.2109375-0.4423828-0.6621094-0.5498047
|
||||
l-0.7851562-0.1821289c-0.8564453-0.2026367-1.2529297-0.5869141-1.2529297-1.2568359
|
||||
C101.1552963,10.6738281,101.8867416,10.1118164,102.9043198,10.1118164z"/>
|
||||
<path fill="#FFFFFF" d="M109.7461166,14.7016602h-0.8564453v-0.715332h-0.0703125
|
||||
c-0.21875,0.5126953-0.6777344,0.8022461-1.3603516,0.8022461c-0.9960938,0-1.5507812-0.6079102-1.5507812-1.6665039v-2.9233398
|
||||
h0.8896484v2.6918945c0,0.7275391,0.2929688,1.0751953,0.9462891,1.0751953
|
||||
c0.7197266,0,1.1123047-0.4262695,1.1123047-1.1333008v-2.6337891h0.8896484V14.7016602z"/>
|
||||
<path fill="#FFFFFF" d="M111.1621323,10.1987305h0.8554688v0.6904297h0.0664062
|
||||
c0.1279297-0.4423828,0.6279297-0.7651367,1.2158203-0.7651367c0.1318359,0,0.3017578,0.012207,0.3964844,0.0371094v0.8769531
|
||||
c-0.0742188-0.0249023-0.3388672-0.0537109-0.4960938-0.0537109c-0.6738281,0-1.1494141,0.4257812-1.1494141,1.0585938
|
||||
v2.6586914h-0.8886719V10.1987305z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M35.2016792,18.0668945h1.859375v12.418457h-1.859375V18.0668945z"/>
|
||||
<path fill="#FFFFFF" d="M39.3012886,22.6108398l1.015625-4.5439453h1.8066406l-1.2304688,4.5439453H39.3012886z"/>
|
||||
<path fill="#FFFFFF" d="M49.1489449,27.1289062h-4.7333984l-1.1367188,3.3564453h-2.0048828l4.4833984-12.418457h2.0830078
|
||||
l4.4833984,12.418457H50.284687L49.1489449,27.1289062z M44.9057808,25.5800781h3.7519531l-1.8496094-5.4477539h-0.0517578
|
||||
L44.9057808,25.5800781z"/>
|
||||
<path fill="#FFFFFF" d="M62.0063667,25.9589844c0,2.8134766-1.5058594,4.6210938-3.7783203,4.6210938
|
||||
c-1.2900391,0-2.3144531-0.5771484-2.8486328-1.5839844h-0.0429688v4.484375h-1.8583984V21.4311523h1.7988281v1.5058594h0.0341797
|
||||
c0.5166016-0.9716797,1.6181641-1.6005859,2.8828125-1.6005859C60.4917183,21.3364258,62.0063667,23.152832,62.0063667,25.9589844
|
||||
z M60.0962105,25.9589844c0-1.8334961-0.9472656-3.0385742-2.3925781-3.0385742c-1.4199219,0-2.375,1.2304688-2.375,3.0385742
|
||||
c0,1.8242188,0.9550781,3.0458984,2.375,3.0458984C59.1489449,29.0048828,60.0962105,27.8085938,60.0962105,25.9589844z"/>
|
||||
<path fill="#FFFFFF" d="M71.970726,25.9589844c0,2.8134766-1.5058594,4.6210938-3.7783203,4.6210938
|
||||
c-1.2900391,0-2.3144531-0.5771484-2.8486328-1.5839844h-0.0429688v4.484375h-1.857914V21.4311523h1.7983437v1.5058594h0.0341797
|
||||
c0.5166016-0.9716797,1.6181641-1.6005859,2.8828125-1.6005859C70.4560776,21.3364258,71.970726,23.152832,71.970726,25.9589844z
|
||||
M70.0605698,25.9589844c0-1.8334961-0.9472656-3.0385742-2.3925781-3.0385742c-1.4199219,0-2.375,1.2304688-2.375,3.0385742
|
||||
c0,1.8242188,0.9550781,3.0458984,2.375,3.0458984C69.1133041,29.0048828,70.0605698,27.8085938,70.0605698,25.9589844z"/>
|
||||
<path fill="#FFFFFF" d="M78.5566635,27.0253906c0.1376953,1.2314453,1.3339844,2.0400391,2.96875,2.0400391
|
||||
c1.5664062,0,2.6933594-0.8085938,2.6933594-1.9189453c0-0.9638672-0.6796875-1.5410156-2.2890625-1.9365234l-1.609375-0.3881836
|
||||
c-2.2802734-0.5507812-3.3388672-1.6171875-3.3388672-3.3476562c0-2.1425781,1.8671875-3.6142578,4.5185547-3.6142578
|
||||
c2.6240234,0,4.4228516,1.4716797,4.4833984,3.6142578h-1.8759766c-0.1123047-1.2392578-1.1367188-1.9873047-2.6337891-1.9873047
|
||||
s-2.5214844,0.7568359-2.5214844,1.8583984c0,0.8779297,0.6542969,1.3945312,2.2548828,1.7900391l1.3681641,0.3359375
|
||||
c2.5478516,0.6025391,3.6064453,1.6264648,3.6064453,3.4428711c0,2.3232422-1.8505859,3.7783203-4.7939453,3.7783203
|
||||
c-2.7539062,0-4.6132812-1.4208984-4.7333984-3.6669922H78.5566635z"/>
|
||||
<path fill="#FFFFFF" d="M90.1924057,19.2885742v2.1425781h1.7216797v1.4716797h-1.7216797v4.9916992
|
||||
c0,0.7753906,0.3447266,1.1367188,1.1015625,1.1367188c0.1894531,0,0.4912109-0.0263672,0.6113281-0.0429688v1.4628906
|
||||
c-0.2060547,0.0517578-0.6191406,0.0859375-1.0322266,0.0859375c-1.8330078,0-2.5478516-0.6884766-2.5478516-2.4443359V22.902832
|
||||
H87.008812v-1.4716797h1.3164062v-2.1425781H90.1924057z"/>
|
||||
<path fill="#FFFFFF" d="M92.9101791,25.9589844c0-2.8491211,1.6777344-4.6391602,4.2939453-4.6391602
|
||||
c2.625,0,4.2949219,1.7900391,4.2949219,4.6391602c0,2.8564453-1.6611328,4.6386719-4.2949219,4.6386719
|
||||
C94.571312,30.5976562,92.9101791,28.8154297,92.9101791,25.9589844z M99.6054916,25.9589844
|
||||
c0-1.9545898-0.8955078-3.1079102-2.4013672-3.1079102s-2.4013672,1.1621094-2.4013672,3.1079102
|
||||
c0,1.9619141,0.8955078,3.1064453,2.4013672,3.1064453S99.6054916,27.9208984,99.6054916,25.9589844z"/>
|
||||
<path fill="#FFFFFF" d="M103.0312729,21.4311523h1.7724609v1.5410156h0.0429688
|
||||
c0.2841797-1.0244141,1.1103516-1.6357422,2.1777344-1.6357422c0.2666016,0,0.4902344,0.0351562,0.6367188,0.0693359v1.7382812
|
||||
c-0.1464844-0.0605469-0.4736328-0.1123047-0.8349609-0.1123047c-1.1962891,0-1.9365234,0.8095703-1.9365234,2.0834961v5.3701172
|
||||
h-1.8583984V21.4311523z"/>
|
||||
<path fill="#FFFFFF" d="M116.2295151,27.8261719c-0.25,1.6435547-1.8505859,2.7714844-3.8984375,2.7714844
|
||||
c-2.6337891,0-4.2685547-1.7646484-4.2685547-4.5957031c0-2.840332,1.6435547-4.6821289,4.1904297-4.6821289
|
||||
c2.5048828,0,4.0800781,1.7207031,4.0800781,4.4663086v0.6367188h-6.3945312v0.1123047
|
||||
c0,1.5488281,0.9726562,2.5644531,2.4355469,2.5644531c1.0322266,0,1.8417969-0.4902344,2.0908203-1.2734375H116.2295151z
|
||||
M109.9472885,25.1240234h4.5263672c-0.0429688-1.3862305-0.9296875-2.2983398-2.2207031-2.2983398
|
||||
C110.970726,22.8256836,110.0420151,23.7553711,109.9472885,25.1240234z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Calque_2" data-name="Calque 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.5 40">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #a6a6a6;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="livetype">
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-1" d="M116.97,0H9.53c-.37,0-.73,0-1.09,0-.31,0-.61,0-.92.01-.67.02-1.34.06-2,.18-.67.12-1.29.32-1.9.63-.6.31-1.15.71-1.62,1.18-.48.47-.88,1.02-1.18,1.62-.31.61-.51,1.23-.63,1.9-.12.66-.16,1.33-.18,2,0,.31-.01.61-.02.92v23.11c0,.31,0,.61.02.92.02.67.06,1.34.18,2,.12.67.31,1.3.63,1.9.3.6.7,1.14,1.18,1.61.47.48,1.02.88,1.62,1.18.61.31,1.23.51,1.9.63.66.12,1.34.16,2,.18.31,0,.61.01.92.01.37,0,.73,0,1.09,0h107.44c.36,0,.72,0,1.08,0,.3,0,.62,0,.92-.01.67-.02,1.34-.06,2-.18.67-.12,1.29-.32,1.91-.63.6-.3,1.14-.7,1.62-1.18.48-.47.87-1.02,1.18-1.61.31-.61.51-1.23.62-1.9.12-.66.16-1.33.19-2,0-.31,0-.61,0-.92,0-.36,0-.72,0-1.09V9.54c0-.37,0-.73,0-1.09,0-.31,0-.61,0-.92-.02-.67-.06-1.34-.19-2-.11-.67-.31-1.29-.62-1.9-.31-.6-.71-1.15-1.18-1.62-.47-.47-1.02-.87-1.62-1.18-.62-.31-1.24-.51-1.91-.63-.66-.12-1.33-.16-2-.18-.3,0-.62-.01-.92-.01-.36,0-.72,0-1.08,0h0Z"/>
|
||||
<path d="M8.44,39.12c-.3,0-.6,0-.9-.01-.56-.02-1.22-.05-1.87-.16-.61-.11-1.15-.29-1.66-.55-.52-.26-.99-.61-1.4-1.02-.41-.41-.75-.87-1.02-1.4-.26-.5-.44-1.05-.54-1.66-.12-.67-.15-1.36-.17-1.88,0-.21-.01-.91-.01-.91V8.44s0-.69.01-.89c.01-.52.04-1.21.17-1.87.11-.61.28-1.16.54-1.66.27-.52.61-.99,1.02-1.4.41-.41.88-.76,1.4-1.02.51-.26,1.06-.44,1.65-.54.67-.12,1.36-.15,1.88-.16h.9s109.6-.01,109.6-.01h.91c.51.03,1.2.06,1.86.18.6.11,1.15.28,1.67.55.51.26.98.61,1.39,1.02.41.41.75.88,1.02,1.4.26.51.43,1.05.54,1.65.12.63.15,1.28.17,1.89,0,.28,0,.59,0,.89,0,.38,0,.73,0,1.09v20.93c0,.36,0,.72,0,1.08,0,.33,0,.62,0,.93-.02.59-.06,1.24-.17,1.85-.1.61-.28,1.16-.54,1.67-.27.52-.61.99-1.02,1.39-.41.42-.88.76-1.4,1.02-.52.26-1.05.44-1.67.55-.64.12-1.3.15-1.87.16-.29,0-.6.01-.9.01h-1.08s-108.53,0-108.53,0Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-2" d="M24.77,20.3c-.03-2.75,2.25-4.09,2.36-4.15-1.29-1.88-3.29-2.14-3.99-2.16-1.68-.18-3.31,1-4.16,1s-2.19-.99-3.61-.96c-1.83.03-3.54,1.09-4.47,2.73-1.93,3.35-.49,8.27,1.36,10.98.93,1.33,2.01,2.81,3.43,2.75,1.39-.06,1.91-.88,3.58-.88s2.14.88,3.59.85c1.49-.02,2.43-1.33,3.32-2.67,1.07-1.52,1.5-3.02,1.52-3.09-.03-.01-2.89-1.1-2.92-4.4Z"/>
|
||||
<path class="cls-2" d="M22.04,12.21c.75-.93,1.26-2.2,1.11-3.49-1.08.05-2.43.75-3.21,1.66-.69.8-1.3,2.12-1.14,3.36,1.21.09,2.46-.61,3.24-1.53Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-2" d="M37.55,8.73c1.17,0,1.97.81,1.97,1.99s-.83,1.97-2,1.97h-1.38v2.01h-.93v-5.97h2.34ZM36.14,11.88h1.17c.8,0,1.27-.41,1.27-1.15s-.45-1.17-1.27-1.17h-1.17v2.32Z"/>
|
||||
<path class="cls-2" d="M40.73,10.2h.86v.69h.07c.13-.44.63-.77,1.22-.77.13,0,.3.01.4.04v.88c-.07-.02-.34-.05-.5-.05-.67,0-1.15.43-1.15,1.06v2.66h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M47.94,13.49c-.2.81-.92,1.3-1.95,1.3-1.29,0-2.08-.88-2.08-2.32s.81-2.35,2.08-2.35,2.01.86,2.01,2.27v.31h-3.18v.05c.03.79.49,1.29,1.2,1.29.54,0,.91-.19,1.07-.55h.86ZM44.81,12.03h2.27c-.02-.71-.45-1.17-1.11-1.17s-1.12.46-1.17,1.17ZM45.45,9.45l1.04-1.42h1.04l-1.16,1.42h-.92Z"/>
|
||||
<path class="cls-2" d="M52.14,11.67c-.1-.44-.47-.76-1.06-.76-.74,0-1.2.57-1.2,1.53s.46,1.56,1.2,1.56c.56,0,.95-.26,1.06-.74h.86c-.12.91-.81,1.53-1.92,1.53-1.31,0-2.11-.88-2.11-2.35s.8-2.32,2.11-2.32c1.13,0,1.81.66,1.93,1.56h-.86Z"/>
|
||||
<path class="cls-2" d="M53.92,12.45c0-1.45.81-2.34,2.12-2.34s2.12.88,2.12,2.34-.81,2.34-2.12,2.34-2.12-.88-2.12-2.34ZM57.25,12.45c0-.98-.44-1.55-1.21-1.55s-1.21.57-1.21,1.55.43,1.55,1.21,1.55,1.21-.57,1.21-1.55Z"/>
|
||||
<path class="cls-2" d="M59.35,10.2h.86v.72h.07c.2-.51.65-.81,1.25-.81s1.04.32,1.24.81h.07c.23-.49.74-.81,1.37-.81.91,0,1.44.55,1.44,1.49v3.1h-.89v-2.87c0-.61-.29-.91-.87-.91s-.95.41-.95.94v2.83h-.87v-2.96c0-.51-.34-.82-.87-.82s-.95.44-.95,1.02v2.75h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M67.02,10.2h.86v.72h.07c.2-.51.65-.81,1.25-.81s1.04.32,1.24.81h.07c.23-.49.74-.81,1.37-.81.91,0,1.44.55,1.44,1.49v3.1h-.89v-2.87c0-.61-.29-.91-.87-.91s-.95.41-.95.94v2.83h-.87v-2.96c0-.51-.34-.82-.87-.82s-.95.44-.95,1.02v2.75h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M74.43,13.43c0-.81.6-1.28,1.67-1.34l1.22-.07v-.39c0-.48-.31-.74-.92-.74-.5,0-.84.18-.94.5h-.86c.09-.77.82-1.27,1.84-1.27,1.13,0,1.77.56,1.77,1.51v3.08h-.86v-.63h-.07c-.27.45-.76.71-1.35.71-.87,0-1.5-.52-1.5-1.35ZM77.33,13.04v-.38l-1.1.07c-.62.04-.9.25-.9.65s.35.64.83.64c.67,0,1.17-.43,1.17-.98Z"/>
|
||||
<path class="cls-2" d="M79.6,10.2h.86v.72h.07c.22-.5.67-.8,1.34-.8,1,0,1.56.6,1.56,1.67v2.92h-.89v-2.69c0-.72-.31-1.08-.97-1.08s-1.08.44-1.08,1.14v2.63h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M84.58,12.45c0-1.42.73-2.32,1.87-2.32.62,0,1.14.29,1.38.79h.07v-2.47h.89v6.26h-.85v-.71h-.07c-.27.49-.79.79-1.41.79-1.15,0-1.87-.9-1.87-2.33ZM85.5,12.45c0,.96.45,1.53,1.2,1.53s1.21-.58,1.21-1.53-.47-1.53-1.21-1.53-1.2.58-1.2,1.53Z"/>
|
||||
<path class="cls-2" d="M94.05,13.49c-.2.81-.92,1.3-1.95,1.3-1.29,0-2.08-.88-2.08-2.32s.81-2.35,2.08-2.35,2.01.86,2.01,2.27v.31h-3.18v.05c.03.79.49,1.29,1.2,1.29.54,0,.91-.19,1.07-.55h.86ZM90.92,12.03h2.27c-.02-.71-.45-1.17-1.11-1.17s-1.12.46-1.17,1.17Z"/>
|
||||
<path class="cls-2" d="M95.3,10.2h.86v.69h.07c.13-.44.63-.77,1.22-.77.13,0,.3.01.4.04v.88c-.07-.02-.34-.05-.5-.05-.67,0-1.15.43-1.15,1.06v2.66h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M102.9,10.11c1.01,0,1.67.47,1.76,1.27h-.85c-.08-.33-.41-.54-.91-.54s-.87.24-.87.59c0,.27.23.44.72.55l.75.17c.86.2,1.26.57,1.26,1.23,0,.85-.79,1.41-1.87,1.41s-1.77-.48-1.85-1.28h.89c.11.35.44.56.98.56s.95-.25.95-.61c0-.27-.21-.44-.66-.55l-.79-.18c-.86-.2-1.25-.59-1.25-1.26,0-.8.73-1.36,1.75-1.36Z"/>
|
||||
<path class="cls-2" d="M109.74,14.7h-.86v-.72h-.07c-.22.51-.68.8-1.36.8-1,0-1.55-.61-1.55-1.67v-2.92h.89v2.69c0,.73.29,1.08.95,1.08.72,0,1.11-.43,1.11-1.13v-2.63h.89v4.5Z"/>
|
||||
<path class="cls-2" d="M111.16,10.2h.86v.69h.07c.13-.44.63-.77,1.22-.77.13,0,.3.01.4.04v.88c-.07-.02-.34-.05-.5-.05-.67,0-1.15.43-1.15,1.06v2.66h-.89v-4.5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-2" d="M35.2,18.07h1.86v12.42h-1.86v-12.42Z"/>
|
||||
<path class="cls-2" d="M39.3,22.61l1.02-4.54h1.81l-1.23,4.54h-1.59Z"/>
|
||||
<path class="cls-2" d="M49.15,27.13h-4.73l-1.14,3.36h-2l4.48-12.42h2.08l4.48,12.42h-2.04l-1.14-3.36ZM44.9,25.58h3.75l-1.85-5.45h-.05l-1.85,5.45Z"/>
|
||||
<path class="cls-2" d="M62,25.96c0,2.81-1.51,4.62-3.78,4.62-1.29,0-2.31-.58-2.85-1.58h-.04v4.48h-1.86v-12.05h1.8v1.51h.03c.52-.97,1.62-1.6,2.88-1.6,2.3,0,3.81,1.82,3.81,4.62ZM60.09,25.96c0-1.83-.95-3.04-2.39-3.04s-2.38,1.23-2.38,3.04.96,3.05,2.38,3.05,2.39-1.2,2.39-3.05Z"/>
|
||||
<path class="cls-2" d="M71.97,25.96c0,2.81-1.51,4.62-3.78,4.62-1.29,0-2.31-.58-2.85-1.58h-.04v4.48h-1.86v-12.05h1.8v1.51h.03c.52-.97,1.62-1.6,2.88-1.6,2.3,0,3.81,1.82,3.81,4.62ZM70.06,25.96c0-1.83-.95-3.04-2.39-3.04s-2.38,1.23-2.38,3.04.96,3.05,2.38,3.05,2.39-1.2,2.39-3.05Z"/>
|
||||
<path class="cls-2" d="M78.55,27.03c.14,1.23,1.33,2.04,2.97,2.04s2.69-.81,2.69-1.92c0-.96-.68-1.54-2.29-1.94l-1.61-.39c-2.28-.55-3.34-1.62-3.34-3.35,0-2.14,1.87-3.61,4.52-3.61s4.42,1.47,4.48,3.61h-1.88c-.11-1.24-1.14-1.99-2.63-1.99s-2.52.76-2.52,1.86c0,.88.65,1.39,2.25,1.79l1.37.34c2.55.6,3.61,1.63,3.61,3.44,0,2.32-1.85,3.78-4.79,3.78-2.75,0-4.61-1.42-4.73-3.67h1.9Z"/>
|
||||
<path class="cls-2" d="M90.19,19.29v2.14h1.72v1.47h-1.72v4.99c0,.78.34,1.14,1.1,1.14.19,0,.49-.03.61-.04v1.46c-.21.05-.62.09-1.03.09-1.83,0-2.55-.69-2.55-2.44v-5.19h-1.32v-1.47h1.32v-2.14h1.87Z"/>
|
||||
<path class="cls-2" d="M92.91,25.96c0-2.85,1.68-4.64,4.29-4.64s4.29,1.79,4.29,4.64-1.66,4.64-4.29,4.64-4.29-1.78-4.29-4.64ZM99.6,25.96c0-1.95-.9-3.11-2.4-3.11s-2.4,1.16-2.4,3.11.9,3.11,2.4,3.11,2.4-1.14,2.4-3.11Z"/>
|
||||
<path class="cls-2" d="M103.03,21.43h1.77v1.54h.04c.28-1.02,1.11-1.64,2.18-1.64.27,0,.49.04.64.07v1.74c-.15-.06-.47-.11-.83-.11-1.2,0-1.94.81-1.94,2.08v5.37h-1.86v-9.05Z"/>
|
||||
<path class="cls-2" d="M116.23,27.83c-.25,1.64-1.85,2.77-3.9,2.77-2.63,0-4.27-1.76-4.27-4.6s1.64-4.68,4.19-4.68,4.08,1.72,4.08,4.47v.64h-6.39v.11c0,1.55.97,2.56,2.44,2.56,1.03,0,1.84-.49,2.09-1.27h1.76ZM109.94,25.12h4.53c-.04-1.39-.93-2.3-2.22-2.3s-2.21.93-2.31,2.3Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -13,11 +13,14 @@ $(document).ready(function () {
|
||||
target.addClass('is-invalid')
|
||||
target.removeClass('is-valid')
|
||||
|
||||
const isManageIngredients = target.hasClass('manageingredients-autocomplete')
|
||||
|
||||
$.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) {
|
||||
let html = '<ul class="list-group list-group-flush" id="' + prefix + '_list">'
|
||||
|
||||
objects.results.forEach(function (obj) {
|
||||
html += li(prefix + '_' + obj.id, obj[name_field])
|
||||
const extra = isManageIngredients ? ` (${obj.owner_name})` : ''
|
||||
html += li(`${prefix}_${obj.id}`, `${obj[name_field]}${extra}`)
|
||||
})
|
||||
html += '</ul>'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user