mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-11-17 03:57:42 +01:00
Compare commits
9 Commits
6bf21b103f
...
note_sheet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
033c466cf7 | ||
|
|
6a77cfd4dd | ||
|
|
48b1ef9ec8 | ||
|
|
4f016fed38 | ||
|
|
6cffe94bae | ||
|
|
78372807f8 | ||
|
|
b9bf01f2e3 | ||
|
|
624f94823c | ||
|
|
30a598c0b7 |
@@ -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 = ['id', 'name', 'owner', 'allergens', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'owner_name']
|
||||
|
||||
|
||||
class BasicFoodSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
from api.viewsets import ReadProtectedModelViewSet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.utils import timezone
|
||||
from rest_framework.filters import SearchFilter
|
||||
|
||||
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \
|
||||
@@ -114,12 +113,6 @@ class OrderViewSet(ReadProtectedModelViewSet):
|
||||
filterset_fields = ['user', 'activity', 'dish', 'supplements', 'number', ]
|
||||
search_fields = ['$user', '$activity', '$dish', '$supplements', '$number', ]
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
if instance.served and not instance.served_at:
|
||||
instance.served_at = timezone.now()
|
||||
instance.save()
|
||||
|
||||
|
||||
class FoodTransactionViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
|
||||
@@ -6,14 +6,15 @@ 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, AmountInput
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order
|
||||
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, Recipe
|
||||
|
||||
|
||||
class QRCodeForms(forms.ModelForm):
|
||||
@@ -55,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,
|
||||
@@ -98,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,
|
||||
@@ -134,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")
|
||||
|
||||
@@ -142,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
|
||||
@@ -158,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')
|
||||
|
||||
@@ -167,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')
|
||||
|
||||
@@ -181,6 +185,11 @@ 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,
|
||||
@@ -219,7 +228,7 @@ SupplementFormSet = forms.inlineformset_factory(
|
||||
Dish,
|
||||
Supplement,
|
||||
form=SupplementForm,
|
||||
extra=0,
|
||||
extra=1,
|
||||
)
|
||||
|
||||
|
||||
@@ -243,3 +252,43 @@ class OrderForm(forms.ModelForm):
|
||||
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',)
|
||||
|
||||
|
||||
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(),
|
||||
)
|
||||
|
||||
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,6 +1,7 @@
|
||||
# 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
|
||||
@@ -53,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,
|
||||
@@ -91,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():
|
||||
@@ -142,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')
|
||||
@@ -214,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
|
||||
@@ -224,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)
|
||||
@@ -234,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
|
||||
@@ -243,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()
|
||||
|
||||
@@ -254,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')
|
||||
@@ -445,36 +478,30 @@ class Order(models.Model):
|
||||
self.number = 1
|
||||
else:
|
||||
self.number = last_order.number + 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
elif self.served:
|
||||
if FoodTransaction.objects.filter(order=self).exists():
|
||||
transaction = FoodTransaction.objects.get(order=self)
|
||||
transaction.valid = True
|
||||
transaction.save()
|
||||
else:
|
||||
transaction = FoodTransaction(
|
||||
source=self.user.note,
|
||||
destination=self.activity.organizer.note,
|
||||
amount=self.amount,
|
||||
quantity=1,
|
||||
valid=True,
|
||||
order=self,
|
||||
)
|
||||
transaction.save()
|
||||
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:
|
||||
if FoodTransaction.objects.filter(order=self).exists():
|
||||
transaction = FoodTransaction.objects.get(order=self)
|
||||
transaction.valid = False
|
||||
transaction.save()
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
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.ForeignKey(
|
||||
order = models.OneToOneField(
|
||||
Order,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='transaction',
|
||||
@@ -484,3 +511,52 @@ class FoodTransaction(Transaction):
|
||||
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)
|
||||
|
||||
@@ -21,7 +21,6 @@ function delete_button (button_id, table_id) {
|
||||
* @param table_id: Id of the table to reload
|
||||
*/
|
||||
function serve_button(button_id, table_id, current_state) {
|
||||
console.log("update")
|
||||
const new_state = !current_state;
|
||||
$.ajax({
|
||||
url: '/api/food/order/' + button_id + '/',
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, Dish, Order
|
||||
from .models import Food, Dish, Order, Recipe
|
||||
|
||||
|
||||
class FoodTable(tables.Table):
|
||||
@@ -32,7 +32,7 @@ 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),
|
||||
@@ -106,16 +106,30 @@ class OrderTable(tables.Table):
|
||||
get_current_request(), "food.change_order_saved",
|
||||
record) else '')}}, verbose_name=_("Serve"), )
|
||||
|
||||
request = tables.Column(
|
||||
orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('ordered_at', 'user', 'dish', 'supplements', 'request', 'serve', 'delete')
|
||||
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',
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% 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" %}
|
||||
|
||||
@@ -16,7 +16,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% crispy form %}
|
||||
</div>
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Ajouter des suppléments (optionnel)" %}
|
||||
{% trans "Add supplements (optional)" %}
|
||||
</h3>
|
||||
{{ formset.management_form }}
|
||||
<table class="table table-condensed table-striped">
|
||||
|
||||
@@ -47,6 +47,9 @@ 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>
|
||||
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_use" pk=food.pk %}">
|
||||
{% trans "Use a recipe" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
|
||||
{% trans "Return to the food list" %}
|
||||
|
||||
@@ -70,6 +70,16 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% trans "New meal" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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 }}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
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 %}
|
||||
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, 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,15 +9,15 @@ 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'),
|
||||
@@ -28,5 +28,11 @@ urlpatterns = [
|
||||
path('activity/<int:activity_pk>/order/', views.OrderCreateView.as_view(), name='order_create'),
|
||||
path('activity/<int:activity_pk>/orders/', views.OrderListView.as_view(), name='order_list'),
|
||||
path('activity/<int:activity_pk>/orders/served', views.ServedOrderListView.as_view(), name='served_order_list'),
|
||||
path('activity/orders/<int:pk>/delete/', views.OrderDeleteView.as_view(), name='order_delete'),
|
||||
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'),
|
||||
]
|
||||
|
||||
@@ -8,8 +8,9 @@ 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
|
||||
@@ -22,12 +23,13 @@ from activity.models import Activity
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
|
||||
|
||||
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish
|
||||
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement, Recipe
|
||||
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
|
||||
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
|
||||
BasicFoodUpdateForms, TransformedFoodUpdateForms, \
|
||||
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm
|
||||
from .tables import FoodTable, DishTable, OrderTable
|
||||
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm, RecipeForm, \
|
||||
RecipeIngredientsForm, RecipeIngredientsFormSet, UseRecipeForm
|
||||
from .tables import FoodTable, DishTable, OrderTable, RecipeTable
|
||||
from .utils import pretty_duration
|
||||
|
||||
|
||||
@@ -118,6 +120,10 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
|
||||
|
||||
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
|
||||
@@ -235,6 +241,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)
|
||||
|
||||
@@ -294,34 +302,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):
|
||||
@@ -345,6 +361,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):
|
||||
@@ -373,13 +390,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}')
|
||||
@@ -409,6 +428,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()
|
||||
@@ -422,7 +442,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):
|
||||
@@ -455,7 +475,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"]:
|
||||
@@ -464,6 +484,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(),
|
||||
@@ -591,7 +613,8 @@ class DishCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
formset = SupplementFormSet(self.request.POST, instance=form.instance)
|
||||
if formset.is_valid():
|
||||
for f in formset:
|
||||
if f.is_valid():
|
||||
# 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:
|
||||
@@ -656,12 +679,54 @@ class DishUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
form_class = DishForm
|
||||
extra_context = {"title": _("Update a dish")}
|
||||
|
||||
def get_form(self, **kwargs):
|
||||
form = super().get_form(**kwargs)
|
||||
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):
|
||||
"""
|
||||
@@ -726,7 +791,7 @@ class OrderListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, L
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
|
||||
return Order.objects.filter(activity=activity)
|
||||
return Order.objects.filter(activity=activity).order_by('number')
|
||||
|
||||
def get_tables(self):
|
||||
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
|
||||
@@ -787,17 +852,222 @@ class ServedOrderListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
|
||||
return context
|
||||
|
||||
|
||||
class OrderDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
|
||||
class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
Delete an order
|
||||
The view to display useful information for the kitchen
|
||||
"""
|
||||
model = Order
|
||||
extra_context = {"title": _('Delete dish')}
|
||||
table_class = OrderTable
|
||||
template_name = 'food/kitchen.html'
|
||||
extra_context = {'title': _('Kitchen')}
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
if self.get_object().served:
|
||||
raise PermissionDenied(_("This order cannot be deleted because it has already been served"))
|
||||
return super().delete(request, *args, **kwargs)
|
||||
def get_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:order_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})
|
||||
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:
|
||||
query |= Q(name__istartswith=name)
|
||||
|
||||
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})
|
||||
|
||||
@@ -74,7 +74,6 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
# For each product, we save it
|
||||
formset = ProductFormSet(self.request.POST, instance=form.instance)
|
||||
print(formset)
|
||||
if formset.is_valid():
|
||||
for f in formset:
|
||||
# We don't save the product if the designation is not entered, ie. if the line is empty
|
||||
|
||||
@@ -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