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

Compare commits

..

57 Commits

Author SHA1 Message Date
108a56745c Corrections de quelques bugs (par Quark) 2025-05-10 19:24:05 +02:00
fadb289ed7 migrations 2025-05-09 19:48:04 +02:00
905fc6e7cc Merge branch 'delete_activity' into 'main'
Delete activity

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

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

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

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

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

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

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

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

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

[1] 3dd5f6e3e0

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

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

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

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

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

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

Signed-off-by: Alexis Mercier des Rochettes <apernouille@gmail.com>
2025-03-24 17:36:30 +01:00
60355196ce Merge branch 'openid-connect' into 'main'
Openid connect

See merge request bde/nk20!293
2025-03-20 18:42:51 +01:00
9bffb32a5e documentation 2025-03-20 17:36:38 +01:00
5ef019c5c2 Merge branch 'notekfet_wrapped' into 'main'
Rewrite script and add test

See merge request bde/nk20!300
2025-03-18 16:11:33 +01:00
8da62e62fb Rewrite script and add test 2025-03-18 15:53:02 +01:00
56a43396d4 Merge branch 'Add_some_permissions' into 'main'
Add some permissions

See merge request bde/nk20!296
2025-03-17 13:16:01 +01:00
7966d6f397 Update file initial.json 2025-03-17 13:15:07 +01:00
cb61c511ce Merge branch 'notekfet_wrapped' into 'main'
Another tables and doc

See merge request bde/nk20!299
2025-03-14 00:44:57 +01:00
25bfa575ed Another tables and doc 2025-03-14 00:31:25 +01:00
e21d9fcfbe Merge branch 'notekfet_wrapped' into 'main'
Notekfet wrapped

See merge request bde/nk20!298
2025-03-14 00:11:04 +01:00
b293904525 Another tables and doc 2025-03-13 23:56:10 +01:00
bd7e6b8ad4 add table, add some translation 2025-03-13 21:08:52 +01:00
a208a4fa25 Merge branch 'bde_color' into 'main'
Resize and compress image, add shiny button

See merge request bde/nk20!297
2025-03-13 00:44:45 +01:00
4799b2c52d Resize and compress image, add shiny button 2025-03-12 23:42:37 +01:00
562dcfb908 Update file initial.json 2025-03-11 19:34:56 +01:00
12ef258ff0 Update file initial.json 2025-03-11 19:27:02 +01:00
2ae32ee3b6 Update file initial.json 2025-03-11 19:26:49 +01:00
ec1bd45481 Update file initial.json 2025-03-11 19:14:09 +01:00
370a9a069e Merge branch 'bde_color' into 'main'
Rave Part[list] colors

See merge request bde/nk20!295
2025-03-11 10:31:02 +01:00
7f0a3784e9 Rave Part[list] colors 2025-03-11 10:15:11 +01:00
6c63c6417c Typesetting 2025-03-08 16:08:40 +01:00
4563b2b640 Added configusation for OpenID support, along with installation information 2025-03-08 16:04:25 +01:00
76 changed files with 3054 additions and 2024 deletions

1
.gitignore vendored
View File

@ -48,6 +48,7 @@ backups/
env/
venv/
db.sqlite3
shell.nix
# ansibles customs host
ansible/host_vars/*.yaml

View File

@ -58,7 +58,13 @@ Bien que cela permette de créer une instance sur toutes les distributions,
(env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
```
6. Enjoy :
6. (Optionnel) **Création d'une clé privée OpenID Connect**
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
7. Enjoy :
```bash
(env)$ ./manage.py runserver 0.0.0.0:8000
@ -228,7 +234,13 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
(env)$ ./manage.py check # pas de bêtise qui traine
(env)$ ./manage.py migrate
7. *Enjoy \o/*
7. **Création d'une clé privée OpenID Connect**
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
8. *Enjoy \o/*
### Installation avec Docker

View File

@ -35,7 +35,7 @@ class GuestAdmin(admin.ModelAdmin):
"""
Admin customisation for Guest
"""
list_display = ('last_name', 'first_name', 'activity', 'inviter')
list_display = ('last_name', 'first_name', 'school', 'activity', 'inviter')
form = GuestForm

View File

@ -51,9 +51,9 @@ class GuestViewSet(ReadProtectedModelViewSet):
queryset = Guest.objects.order_by('id')
serializer_class = GuestSerializer
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'school', 'inviter', 'inviter__alias__name',
'inviter__alias__normalized_name', ]
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
search_fields = ['$activity__name', '$last_name', '$first_name', '$school', '$inviter__user__email', '$inviter__alias__name',
'$inviter__alias__normalized_name', ]

View File

@ -107,7 +107,7 @@ class GuestForm(forms.ModelForm):
class Meta:
model = Guest
fields = ('last_name', 'first_name', 'inviter', )
fields = ('last_name', 'first_name', 'school', 'inviter', )
widgets = {
"inviter": Autocomplete(
NoteUser,

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.20 on 2025-03-25 09:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activity", "0005_alter_opener_options_alter_opener_opener"),
]
operations = [
migrations.AddField(
model_name="guest",
name="school",
field=models.CharField(default="", max_length=255, verbose_name="school"),
preserve_default=False,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.20 on 2025-05-08 19:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('activity', '0006_guest_school'),
]
operations = [
migrations.AlterField(
model_name='guest',
name='activity',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='activity.activity'),
),
]

View File

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

View File

@ -51,11 +51,11 @@ class GuestTable(tables.Table):
}
model = Guest
template_name = 'django_tables2/bootstrap4.html'
fields = ("last_name", "first_name", "inviter", )
fields = ("last_name", "first_name", "inviter", "school")
def render_entry(self, record):
if record.has_entry:
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(timezone.localtime(record.entry.time))))
return mark_safe('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> '
'{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize()))

View File

@ -95,5 +95,23 @@ SPDX-License-Identifier: GPL-3.0-or-later
errMsg(xhr.responseJSON);
});
});
$("#delete_activity").click(function () {
if (!confirm("{% trans 'Are you sure you want to delete this activity?' %}")) {
return;
}
$.ajax({
url: "/api/activity/activity/{{ activity.pk }}/",
type: "DELETE",
headers: {
"X-CSRFTOKEN": CSRF_TOKEN
}
}).done(function () {
addMsg("{% trans 'Activity deleted' %}", "success");
window.location.href = "/activity/"; // Redirige vers la liste des activités
}).fail(function (xhr) {
errMsg(xhr.responseJSON);
});
});
</script>
{% endblock %}

View File

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

View File

@ -50,6 +50,7 @@ class TestActivities(TestCase):
inviter=self.user.note,
last_name="GUEST",
first_name="Guest",
school="School",
)
def test_activity_list(self):
@ -156,6 +157,7 @@ class TestActivities(TestCase):
inviter=self.user.note.id,
last_name="GUEST2",
first_name="Guest",
school="School",
))
self.assertEqual(response.status_code, 200)
@ -167,6 +169,7 @@ class TestActivities(TestCase):
inviter=self.user.note.id,
last_name="GUEST2",
first_name="Guest",
school="School",
))
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
@ -200,6 +203,7 @@ class TestActivityAPI(TestAPI):
inviter=self.user.note,
last_name="GUEST",
first_name="Guest",
school="School",
)
self.entry = Entry.objects.create(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,60 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Meal served" %}
</h3>
{% if can_create_meal %}
<div class="card-footer">
<a class="btn btn-sm btn-success" href="{% url 'food:transformed_create' %}" data-turbolinks="false">
{% trans 'New meal' %}
</a>
</div>
{% endif %}
{% if served.data %}
{% render_table served %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no meal served." %}
</div>
</div>
{% endif %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Open" %}
</h3>
{% if open.data %}
{% render_table open %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no free meal." %}
</div>
</div>
{% endif %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "All meals" %}
</h3>
{% if table.data %}
{% render_table table %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no meal." %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

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

View File

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

View File

@ -0,0 +1,170 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from api.tests import TestAPI
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
from ..models import Allergen, BasicFood, TransformedFood, QRCode
class TestFood(TestCase):
"""
Test food
"""
fixtures = ('initial',)
def setUp(self):
self.user = User.objects.create_superuser(
username='admintoto',
password='toto1234',
email='toto@example.com'
)
self.client.force_login(self.user)
sess = self.client.session
sess['permission_mask'] = 42
sess.save()
self.allergen = Allergen.objects.create(
name='allergen',
)
self.basicfood = BasicFood.objects.create(
name='basicfood',
owner_id=1,
expiry_date=timezone.now(),
is_ready=False,
date_type='DLC',
)
self.transformedfood = TransformedFood.objects.create(
name='transformedfood',
owner_id=1,
expiry_date=timezone.now(),
is_ready=False,
)
self.qrcode = QRCode.objects.create(
qr_code_number=1,
food_container=self.basicfood,
)
def test_food_list(self):
"""
Display food list
"""
response = self.client.get(reverse('food:food_list'))
self.assertEqual(response.status_code, 200)
def test_qrcode_create(self):
"""
Display QRCode creation
"""
response = self.client.get(reverse('food:qrcode_create'))
self.assertEqual(response.status_code, 200)
def test_basicfood_create(self):
"""
Display BasicFood creation
"""
response = self.client.get(reverse('food:basicfood_create'))
self.assertEqual(response.status_code, 200)
def test_transformedfood_create(self):
"""
Display TransformedFood creation
"""
response = self.client.get(reverse('food:transformedfood_create'))
self.assertEqual(response.status_code, 200)
def test_food_create(self):
"""
Display Food update
"""
response = self.client.get(reverse('food:food_update'))
self.assertEqual(response.status_code, 200)
def test_food_view(self):
"""
Display Food detail
"""
response = self.client.get(reverse('food:food_view'))
self.assertEqual(response.status_code, 302)
def test_basicfood_view(self):
"""
Display BasicFood detail
"""
response = self.client.get(reverse('food:basicfood_view'))
self.assertEqual(response.status_code, 200)
def test_transformedfood_view(self):
"""
Display TransformedFood detail
"""
response = self.client.get(reverse('food:transformedfood_view'))
self.assertEqual(response.status_code, 200)
def test_add_ingredient(self):
"""
Display add ingredient view
"""
response = self.client.get(reverse('food:add_ingredient'))
self.assertEqual(response.status_code, 200)
class TestFoodAPI(TestAPI):
def setUp(self) -> None:
super().setUP()
self.allergen = Allergen.objects.create(
name='name',
)
self.basicfood = BasicFood.objects.create(
name='basicfood',
owner_id=1,
expiry_date=timezone.now(),
is_ready=False,
date_type='DLC',
)
self.transformedfood = TransformedFood.objects.create(
name='transformedfood',
owner_id=1,
expiry_date=timezone.now(),
is_ready=False,
)
self.qrcode = QRCode.objects.create(
qr_code_number=1,
food_container=self.basicfood,
)
def test_allergen_api(self):
"""
Load Allergen API page and test all filters and permissions
"""
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
def test_basicfood_api(self):
"""
Load BasicFood API page and test all filters and permissions
"""
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
def test_transformedfood_api(self):
"""
Load TransformedFood API page and test all filters and permissions
"""
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
def test_qrcode_api(self):
"""
Load QRCode API page and test all filters and permissions
"""
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')

View File

@ -8,14 +8,14 @@ from . import views
app_name = 'food'
urlpatterns = [
path('', views.TransformedListView.as_view(), name='food_list'),
path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'),
path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'),
path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'),
path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'),
path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'),
path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'),
path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
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'),
]

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

@ -0,0 +1,53 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
seconds = (_('second'), _('seconds'))
minutes = (_('minute'), _('minutes'))
hours = (_('hour'), _('hours'))
days = (_('day'), _('days'))
weeks = (_('week'), _('weeks'))
def plural(x):
if x == 1:
return 0
return 1
def pretty_duration(duration):
"""
I receive datetime.timedelta object
You receive string object
"""
text = []
sec = duration.seconds
d = duration.days
if d >= 7:
w = d // 7
text.append(str(w) + ' ' + weeks[plural(w)])
d -= w * 7
if d > 0:
text.append(str(d) + ' ' + days[plural(d)])
if sec >= 3600:
h = sec // 3600
text.append(str(h) + ' ' + hours[plural(h)])
sec -= h * 3600
if sec >= 60:
m = sec // 60
text.append(str(m) + ' ' + minutes[plural(m)])
sec -= m * 60
if sec > 0:
text.append(str(sec) + ' ' + seconds[plural(sec)])
if len(text) == 0:
return ''
if len(text) == 1:
return text[0]
if len(text) >= 2:
return ', '.join(t for t in text[:-1]) + ' ' + _('and') + ' ' + text[-1]

View File

@ -1,421 +1,508 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import transaction
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from datetime import timedelta
from api.viewsets import is_regex
from django_tables2.views import MultiTableMixin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.views.generic import DetailView, UpdateView
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponseRedirect, Http404
from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView
from django.forms import HiddenInput
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms
from .models import BasicFood, Food, QRCode, TransformedFood
from .tables import TransformedFoodTable
from .models import Food, BasicFood, TransformedFood, QRCode
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms
from .tables import FoodTable
from .utils import pretty_duration
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
A view to add an ingredient
Display Food
"""
model = Food
template_name = 'food/add_ingredient_form.html'
extra_context = {"title": _("Add the ingredient")}
form_class = AddIngredientForms
tables = [FoodTable, FoodTable, FoodTable, ]
extra_context = {"title": _('Food')}
template_name = 'food/food_list.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["pk"] = self.kwargs["pk"]
return context
def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct()
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
food = Food.objects.get(pk=self.kwargs['pk'])
add_ingredient_form = AddIngredientForms(data=self.request.POST)
if food.is_ready:
form.add_error(None, _("The product is already prepared"))
return self.form_invalid(form)
if not add_ingredient_form.is_valid():
return self.form_invalid(form)
def get_tables(self):
bureau_role_pk = 4
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
user=self.request.user, roles=bureau_role_pk).filter(
date_end__gte=timezone.now()))
# We flip logic ""fully used = not is_active""
food.is_active = not food.is_active
# Save the aliment and the allergens associed
for transformed_pk in self.request.POST.getlist('ingredient'):
transformed = TransformedFood.objects.get(pk=transformed_pk)
if not transformed.is_ready:
transformed.ingredient.add(food)
transformed.update()
food.save()
tables = [FoodTable] * (clubs.count() + 3)
self.tables = tables
tables = super().get_tables()
tables[0].prefix = 'search-'
tables[1].prefix = 'open-'
tables[2].prefix = 'served-'
for i in range(clubs.count()):
tables[i + 3].prefix = clubs[i].name
return tables
return HttpResponseRedirect(self.get_success_url())
def get_tables_data(self):
# table search
qs = self.get_queryset().order_by('name')
if "search" in self.request.GET and self.request.GET['search']:
pattern = self.request.GET['search']
def get_success_url(self, **kwargs):
return reverse('food:food_list')
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to update a basic food
"""
model = BasicFood
form_class = BasicFoodForms
template_name = 'food/basicfood_form.html'
extra_context = {"title": _("Update an aliment")}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
basic_food_form = BasicFoodForms(data=self.request.POST)
if not basic_food_form.is_valid():
return self.form_invalid(form)
ans = super().form_valid(form)
form.instance.update()
return ans
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:food_view', kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
return context
class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
A view to see a food
"""
model = Food
extra_context = {"title": _("Details of:")}
context_object_name = "food"
# check regex
valid_regex = is_regex(pattern)
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else ''
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}))
else:
qs = qs.none()
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table open
open_table = self.get_queryset().order_by('expiry_date').filter(
Q(polymorphic_ctype__model='transformedfood')
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
expiry_date__lt=timezone.now(), end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table served
served_table = self.get_queryset().order_by('-pk').filter(
end_of_life='', is_ready=True).exclude(
Q(polymorphic_ctype__model='basicfood',
basicfood__date_type='DLC',
expiry_date__lte=timezone.now(),)
| Q(polymorphic_ctype__model='transformedfood',
expiry_date__lte=timezone.now(),
))
# tables club
bureau_role_pk = 4
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
user=self.request.user, roles=bureau_role_pk).filter(
date_end__gte=timezone.now()))
club_table = []
for club in clubs:
club_table.append(self.get_queryset().order_by('expiry_date').filter(
owner=club, end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view')
))
return [search_table, open_table, served_table] + club_table
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food")
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
tables = context['tables']
# for extends base_search.html we need to name 'search_table' in 'table'
for name, table in zip(['table', 'open', 'served'], tables):
context[name] = table
context['club_tables'] = tables[3:]
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
return context
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
#####################################################################
# TO DO
# - this feature is very pratical for meat or fish, nevertheless we can implement this later
# - fix picture save
# - implement solution crop and convert image (reuse or recode ImageForm from members apps)
#####################################################################
class QRCodeCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
"""
A view to add a basic food with a qrcode
"""
model = BasicFood
form_class = BasicFoodForms
template_name = 'food/basicfood_form.html'
extra_context = {"title": _("Add a new basic food with QRCode")}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
basic_food_form = BasicFoodForms(data=self.request.POST)
if not basic_food_form.is_valid():
return self.form_invalid(form)
# Save the aliment and the allergens associed
basic_food = form.save(commit=False)
# We assume the date of labeling and the same as the date of arrival
basic_food.arrival_date = timezone.now()
basic_food.is_ready = False
basic_food.is_active = True
basic_food.was_eaten = False
basic_food._force_save = True
basic_food.save()
basic_food.refresh_from_db()
qrcode = QRCode()
qrcode.qr_code_number = self.kwargs['slug']
qrcode.food_container = basic_food
qrcode.save()
return super().form_valid(form)
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
def get_sample_object(self):
# We choose a club which may work or BDE else
owner_id = 1
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id)
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
owner_id = club_id
return BasicFood(
name="",
expiry_date=timezone.now(),
owner_id=owner_id,
)
def get_context_data(self, **kwargs):
# Some field are hidden on create
context = super().get_context_data(**kwargs)
form = context['form']
form.fields['is_active'].widget = HiddenInput()
form.fields['was_eaten'].widget = HiddenInput()
copy = self.request.GET.get('copy', None)
if copy is not None:
basic = BasicFood.objects.get(pk=copy)
for field in ['date_type', 'expiry_date', 'name', 'owner']:
form.fields[field].initial = getattr(basic, field)
for field in ['allergens']:
form.fields[field].initial = getattr(basic, field).all()
return context
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
A view to add a new qrcode
A view to add qrcode
"""
model = QRCode
template_name = 'food/create_qrcode_form.html'
template_name = 'food/qrcode.html'
form_class = QRCodeForms
extra_context = {"title": _("Add a new QRCode")}
def get(self, *args, **kwargs):
qrcode = kwargs["slug"]
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs))
pk = self.model.objects.get(qr_code_number=qrcode).food_container.pk
return HttpResponseRedirect(reverse_lazy("food:food_view", kwargs={"pk": pk}))
else:
return super().get(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["slug"] = self.kwargs["slug"]
context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10]
return context
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
qrcode_food_form = QRCodeForms(data=self.request.POST)
if not qrcode_food_form.is_valid():
return self.form_invalid(form)
# Save the qrcode
qrcode = form.save(commit=False)
qrcode.qr_code_number = self.kwargs["slug"]
qrcode.qr_code_number = self.kwargs['slug']
qrcode._force_save = True
qrcode.save()
qrcode.refresh_from_db()
return super().form_valid(form)
qrcode.food_container.save()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['slug'] = self.kwargs['slug']
# get last 10 BasicFood objects with distincts 'name' ordered by '-pk'
# we can't use .distinct and .order_by with differents columns hence the generator
context['last_items'] = [food for food in BasicFood.get_lastests_objects(10, 'name', '-pk')]
return context
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse_lazy('food:food_view', kwargs={'pk': self.object.food_container.pk})
def get_sample_object(self):
return QRCode(
qr_code_number=self.kwargs['slug'],
food_container_id=1,
)
class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
A view to add basicfood
"""
model = BasicFood
form_class = BasicFoodForms
extra_context = {"title": _("Add an aliment")}
template_name = "food/food_update.html"
def get_sample_object(self):
# We choose a club which may work or BDE else
food = BasicFood(
name="",
owner_id=1,
expiry_date=timezone.now(),
is_ready=True,
arrival_date=timezone.now(),
date_type='DLC',
)
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food.owner_id = club_id
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
return food
return food
@transaction.atomic
def form_valid(self, form):
if QRCode.objects.filter(qr_code_number=self.kwargs['slug']).count() > 0:
return HttpResponseRedirect(reverse_lazy('food:qrcode_create', kwargs={'slug': self.kwargs['slug']}))
food_form = BasicFoodForms(data=self.request.POST)
if not food_form.is_valid():
return self.form_invalid(form)
food = form.save(commit=False)
food.is_ready = False
food.save()
food.refresh_from_db()
qrcode = QRCode()
qrcode.qr_code_number = self.kwargs['slug']
qrcode.food_container = food
qrcode.save()
return super().form_valid(form)
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
return reverse_lazy('food:basicfood_view', kwargs={"pk": self.object.pk})
def get_sample_object(self):
return QRCode(
qr_code_number=self.kwargs["slug"],
food_container_id=1
)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
A view to see a qrcode
"""
model = QRCode
extra_context = {"title": _("QRCode")}
context_object_name = "qrcode"
slug_field = "qr_code_number"
def get(self, *args, **kwargs):
qrcode = kwargs["slug"]
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
return super().get(*args, **kwargs)
copy = self.request.GET.get('copy', None)
if copy is not None:
food = BasicFood.objects.get(pk=copy)
print(context['form'].fields)
for field in context['form'].fields:
if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all()
else:
return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs))
context['form'].fields[field].initial = getattr(food, field)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
qr_code_number = self.kwargs['slug']
qrcode = self.model.objects.get(qr_code_number=qr_code_number)
model = qrcode.food_container.polymorphic_ctype.model
if model == "basicfood":
context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood")
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood")
if model == "transformedfood":
context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood")
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
return context
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
A view to add a tranformed food
A view to add transformedfood
"""
model = TransformedFood
template_name = 'food/transformedfood_form.html'
form_class = TransformedFoodForms
extra_context = {"title": _("Add a new meal")}
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
transformed_food_form = TransformedFoodForms(data=self.request.POST)
if not transformed_food_form.is_valid():
return self.form_invalid(form)
# Save the aliment and allergens associated
transformed_food = form.save(commit=False)
transformed_food.expiry_date = transformed_food.creation_date
transformed_food.is_active = True
transformed_food.is_ready = False
transformed_food.was_eaten = False
transformed_food._force_save = True
transformed_food.save()
transformed_food.refresh_from_db()
ans = super().form_valid(form)
transformed_food.update()
return ans
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:food_view', kwargs={"pk": self.object.pk})
extra_context = {"title": _("Add a meal")}
template_name = "food/food_update.html"
def get_sample_object(self):
# We choose a club which may work or BDE else
owner_id = 1
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = TransformedFood(name="",
creation_date=timezone.now(),
expiry_date=timezone.now(),
owner_id=club_id)
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
owner_id = club_id
break
return TransformedFood(
food = TransformedFood(
name="",
owner_id=owner_id,
creation_date=timezone.now(),
owner_id=1,
expiry_date=timezone.now(),
is_ready=True,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food.owner_id = club_id
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
return food
# Some field are hidden on create
form = context['form']
form.fields['is_active'].widget = HiddenInput()
form.fields['is_ready'].widget = HiddenInput()
form.fields['was_eaten'].widget = HiddenInput()
form.fields['shelf_life'].widget = HiddenInput()
return food
return context
@transaction.atomic
def form_valid(self, form):
form.instance.expiry_date = timezone.now() + timedelta(days=3)
form.instance.is_ready = False
return super().form_valid(form)
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
MAX_FORMS = 10
class ManageIngredientsView(LoginRequiredMixin, UpdateView):
"""
A view to update transformed product
A view to manage ingredient for a transformed food
"""
model = TransformedFood
template_name = 'food/transformedfood_form.html'
form_class = TransformedFoodForms
extra_context = {'title': _('Update a meal')}
fields = ['ingredients']
extra_context = {"title": _("Manage ingredients of:")}
template_name = 'food/manage_ingredients.html'
@transaction.atomic
def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy()
self.object.ingredients.clear()
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
prefix = 'form-' + str(i) + '-'
if form.data[prefix + 'qrcode'] not in ['0', '']:
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
elif form.data[prefix + 'name'] != '':
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
# We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear()
for ingredient in self.object.ingredients.iterator():
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['title'] += ' ' + self.object.name
formset = ManageIngredientsFormSet()
ingredients = self.object.ingredients.all()
formset.extra += ingredients.count() + MAX_FORMS
context['form'] = ManageIngredientsForm()
context['ingredients_count'] = ingredients.count()
display = [True] * (1 + ingredients.count()) + [False] * (formset.extra - ingredients.count() - 1)
context['formset'] = zip(display, formset)
context['ingredients'] = []
for ingredient in ingredients:
qr = QRCode.objects.filter(food_container=ingredient)
context['ingredients'].append({
'food_pk': ingredient.pk,
'food_name': ingredient.name,
'qr_pk': '' if qr.count() == 0 else qr[0].pk,
'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number,
'fully_used': 'true' if ingredient.end_of_life else '',
})
return context
def get_success_url(self, **kwargs):
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to add ingredient to a meal
"""
model = Food
extra_context = {"title": _("Add the ingredient:")}
form_class = AddIngredientForms
template_name = 'food/food_update.html'
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['title'] += ' ' + self.object.name
return context
@transaction.atomic
def form_valid(self, form):
meals = TransformedFood.objects.filter(pk__in=form.data.getlist('ingredients')).all()
if not meals:
return HttpResponseRedirect(reverse_lazy('food:food_view', kwargs={"pk": self.object.pk}))
for meal in meals:
old_ingredients = list(meal.ingredients.all()).copy()
old_allergens = list(meal.allergens.all()).copy()
meal.ingredients.add(self.object.pk)
# update allergen and expiry date if necessary
if not (self.object.polymorphic_ctype.model == 'basicfood'
and self.object.date_type == 'DDM'):
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
meal.allergens.set(meal.allergens.union(self.object.allergens.all()))
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
if 'fully_used' in form.data:
if not self.object.end_of_life:
self.object.end_of_life = _(f'Food fully used in : {meal.name}')
else:
self.object.end_of_life += ', ' + meal.name
if 'fully_used' in form.data:
self.object.is_ready = False
self.object.save()
# We redirect only the first parent
parent_pk = meals[0].pk
return HttpResponseRedirect(self.get_success_url(parent_pk=parent_pk))
def get_success_url(self, **kwargs):
return reverse_lazy('food:transformedfood_view', kwargs={"pk": kwargs['parent_pk']})
class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to update Food
"""
model = Food
extra_context = {"title": _("Update an aliment")}
template_name = 'food/food_update.html'
@transaction.atomic
def form_valid(self, form):
form.instance.creater = self.request.user
transformedfood_form = TransformedFoodForms(data=self.request.POST)
if not transformedfood_form.is_valid():
return self.form_invalid(form)
food = Food.objects.get(pk=self.kwargs['pk'])
old_allergens = list(food.allergens.all()).copy()
if food.polymorphic_ctype.model == 'transformedfood':
old_ingredients = food.ingredients.all()
form.instance.shelf_life = timedelta(
seconds=int(form.data['shelf_life']) * 60 * 60)
food_form = self.get_form_class()(data=self.request.POST)
if not food_form.is_valid():
return self.form_invalid(form)
ans = super().form_valid(form)
form.instance.update()
if food.polymorphic_ctype.model == 'transformedfood':
form.instance.save(old_ingredients=old_ingredients)
else:
form.instance.save(old_allergens=old_allergens)
return ans
def get_form_class(self, **kwargs):
food = Food.objects.get(pk=self.kwargs['pk'])
if food.polymorphic_ctype.model == 'basicfood':
return BasicFoodUpdateForms
else:
return TransformedFoodUpdateForms
def get_form(self, **kwargs):
form = super().get_form(**kwargs)
if 'shelf_life' in form.initial:
hours = form.initial['shelf_life'].days * 24 + form.initial['shelf_life'].seconds // 3600
form.initial['shelf_life'] = hours
return form
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse('food:food_view', kwargs={"pk": self.object.pk})
return reverse_lazy('food:food_view', kwargs={"pk": self.object.pk})
class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
A view to see a food
"""
model = Food
extra_context = {"title": _('Details of:')}
context_object_name = "food"
template_name = "food/food_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"]
fields = dict([(field, getattr(self.object, field)) for field in fields])
if fields["is_ready"]:
fields["is_ready"] = _("Yes")
else:
fields["is_ready"] = _("No")
fields["allergens"] = ", ".join(
allergen.name for allergen in fields["allergens"].all())
context["fields"] = [(
Food._meta.get_field(field).verbose_name.capitalize(),
value) for field, value in fields.items()]
context["meals"] = self.object.transformed_ingredient_inv.all()
context["update"] = PermissionBackend.check_perm(self.request, "food.change_food")
context["add_ingredient"] = (self.object.end_of_life == '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood"))
return context
def get(self, *args, **kwargs):
if Food.objects.filter(pk=kwargs['pk']).count() != 1:
return Http404
model = Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model
if 'stop_redirect' in kwargs and kwargs['stop_redirect']:
return super().get(*args, **kwargs)
kwargs = {'pk': kwargs['pk']}
if model == 'basicfood':
return HttpResponseRedirect(reverse_lazy("food:basicfood_view", kwargs=kwargs))
return HttpResponseRedirect(reverse_lazy("food:transformedfood_view", kwargs=kwargs))
class TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
Displays ready TransformedFood
"""
model = TransformedFood
tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable]
extra_context = {"title": _("Transformed food")}
def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct()
def get_tables(self):
tables = super().get_tables()
tables[0].prefix = "all-"
tables[1].prefix = "open-"
tables[2].prefix = "served-"
return tables
def get_tables_data(self):
# first table = all transformed food, second table = free, third = served
return [
self.get_queryset().order_by("-creation_date"),
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
.distinct()
.order_by("-creation_date"),
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
.distinct()
.order_by("-creation_date")
]
class BasicFoodDetailView(FoodDetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# We choose a club which should work
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food = TransformedFood(
name="",
owner_id=club_id,
creation_date=timezone.now(),
expiry_date=timezone.now(),
)
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
context['can_create_meal'] = True
break
tables = context["tables"]
for name, table in zip(["table", "open", "served"], tables):
context[name] = table
fields = ['arrival_date', 'date_type']
for field in fields:
context["fields"].append((
BasicFood._meta.get_field(field).verbose_name.capitalize(),
getattr(self.object, field)
))
return context
def get(self, *args, **kwargs):
if Food.objects.filter(pk=kwargs['pk']).count() == 1:
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'basicfood')
return super().get(*args, **kwargs)
class TransformedFoodDetailView(FoodDetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["fields"].append((
TransformedFood._meta.get_field("creation_date").verbose_name.capitalize(),
self.object.creation_date
))
context["fields"].append((
TransformedFood._meta.get_field("shelf_life").verbose_name.capitalize(),
pretty_duration(self.object.shelf_life)
))
context["foods"] = self.object.ingredients.all()
context["manage_ingredients"] = True
return context
def get(self, *args, **kwargs):
if Food.objects.filter(pk=kwargs['pk']).count() == 1:
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood')
return super().get(*args, **kwargs)

View File

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

View File

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

View File

@ -294,3 +294,10 @@ searchbar.addEventListener("keyup", function (e) {
if (firstMatch && e.key === "Enter")
firstMatch.click()
});
function createshiny() {
const list_btn = document.querySelectorAll('.btn-outline-dark')
const shiny_class = list_btn[Math.floor(Math.random() * list_btn.length)].classList
shiny_class.replace('btn-outline-dark', 'btn-outline-dark-shiny')
}
createshiny()

View File

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

View File

@ -324,7 +324,7 @@
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une transaction de ou vers la note d'un club"
"description": "Créer une transaction de ou vers la note d'un club tant que la source reste au dessus de -20 €"
}
},
{
@ -3312,14 +3312,13 @@
"fields": {
"model": [
"food",
"transformedfood"
"qrcode"
],
"query": "{}",
"type": "view",
"mask": 3,
"field": "",
"mask": 2,
"permanent": false,
"description": "Voir tout les plats"
"description": "Voir n'importe quel QR-code"
}
},
{
@ -3328,14 +3327,13 @@
"fields": {
"model": [
"food",
"transformedfood"
"allergen"
],
"query": "{\"owner\": [\"club\"]}",
"query": "{}",
"type": "view",
"mask": 3,
"field": "",
"mask": 1,
"permanent": false,
"description": "Voir tout les plats de son club"
"description": "Voir n'importe quel allergène"
}
},
{
@ -3344,14 +3342,13 @@
"fields": {
"model": [
"food",
"transformedfood"
"food"
],
"query": "{\"is_ready\": true, \"is_active\": true, \"was_eaten\": false}",
"query": "{}",
"type": "view",
"mask": 1,
"field": "",
"mask": 2,
"permanent": false,
"description": "Voir les plats préparés actifs servis"
"description": "Voir n'importe quelle bouffe"
}
},
{
@ -3364,10 +3361,9 @@
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"mask": 2,
"permanent": false,
"description": "Initialiser un QR code de traçabilité"
"description": "Ajouter n'importe quel QR-code"
}
},
{
@ -3376,14 +3372,13 @@
"fields": {
"model": [
"food",
"basicfood"
"food"
],
"query": "{\"owner\": [\"club\"]}",
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"mask": 2,
"permanent": false,
"description": "Créer un nouvel ingrédient pour son club"
"description": "Ajouter n'importe quelle bouffe"
}
},
{
@ -3392,14 +3387,13 @@
"fields": {
"model": [
"food",
"basicfood"
"food"
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"type": "change",
"mask": 2,
"permanent": false,
"description": "Créer un nouvel ingrédient"
"description": "Modifier n'importe quelle bouffe"
}
},
{
@ -3408,14 +3402,13 @@
"fields": {
"model": [
"food",
"basicfood"
"qrcode"
],
"query": "{}",
"query": "{\"food_container__owner\": [\"club\"]}",
"type": "view",
"mask": 3,
"field": "",
"mask": 2,
"permanent": false,
"description": "Voir toute la bouffe"
"description": "Voir un QR-code lié à son club"
}
},
{
@ -3424,14 +3417,13 @@
"fields": {
"model": [
"food",
"basicfood"
"food"
],
"query": "{\"is_active\": true}",
"query": "{\"owner\": [\"club\"]}",
"type": "view",
"mask": 3,
"field": "",
"mask": 2,
"permanent": false,
"description": "Voir toute la bouffe active"
"description": "Voir la bouffe de son club"
}
},
{
@ -3440,14 +3432,13 @@
"fields": {
"model": [
"food",
"basicfood"
"qrcode"
],
"query": "{\"is_active\": true, \"owner\": [\"club\"]}",
"type": "view",
"mask": 3,
"field": "",
"query": "{\"food_container__owner\": [\"club\"]}",
"type": "add",
"mask": 2,
"permanent": false,
"description": "Voir la bouffe active de son club"
"description": "Ajouter un QR-code pour son club"
}
},
{
@ -3456,14 +3447,13 @@
"fields": {
"model": [
"food",
"basicfood"
"food"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"query": "{\"owner\": [\"club\"]}",
"type": "add",
"mask": 2,
"permanent": false,
"description": "Modifier de la bouffe"
"description": "Ajouter de la bouffe appartenant à son club"
}
},
{
@ -3472,286 +3462,28 @@
"fields": {
"model": [
"food",
"basicfood"
"food"
],
"query": "{\"is_active\": true, \"was_eaten\": false}",
"query": "{\"owner\": [\"club\"]}",
"type": "change",
"mask": 3,
"field": "allergens",
"mask": 2,
"permanent": false,
"description": "Modifier les allergènes de la bouffe existante"
"description": "Modifier la bouffe appartenant à son club"
}
},
{
"model": "permission.permission",
"pk": 222,
"fields": {
"model": [
"food",
"basicfood"
],
"query": "{\"is_active\": true, \"was_eaten\": false, \"owner\": [\"club\"]}",
"type": "change",
"mask": 3,
"field": "allergens",
"permanent": false,
"description": "Modifier les allergènes de la bouffe appartenant à son club"
}
},
{
"model": "permission.permission",
"pk": 223,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"permanent": false,
"description": "Créer un plat"
}
},
{
"model": "permission.permission",
"pk": 224,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{\"owner\": [\"club\"]}",
"type": "add",
"mask": 3,
"field": "",
"permanent": false,
"description": "Créer un plat pour son club"
}
},
{
"model": "permission.permission",
"pk": 225,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"permanent": false,
"description": "Modifier tout les plats"
}
},
{
"model": "permission.permission",
"pk": 226,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{\"is_active\": true}",
"type": "change",
"mask": 3,
"field": "was_eaten",
"permanent": false,
"description": "Indiquer si un plat a été mangé"
}
},
{
"model": "permission.permission",
"pk": 227,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{\"is_active\": true, \"owner\": [\"club\"]}",
"type": "change",
"mask": 3,
"field": "is_ready",
"permanent": false,
"description": "Indiquer si un plat de son club est prêt"
}
},
{
"model": "permission.permission",
"pk": 228,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{\"is_active\": true}",
"type": "change",
"mask": 3,
"field": "is_active",
"permanent": false,
"description": "Archiver un plat"
}
},
{
"model": "permission.permission",
"pk": 229,
"fields": {
"model": [
"food",
"basicfood"
],
"query": "{\"is_active\": true}",
"type": "change",
"mask": 3,
"field": "is_active",
"permanent": false,
"description": "Archiver de la bouffe"
}
},
{
"model": "permission.permission",
"pk": 230,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{\"is_active\": true}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir tout les plats actifs"
}
},
{
"model": "permission.permission",
"pk": 231,
"fields": {
"model": [
"food",
"qrcode"
],
"query": "{}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir tous les QR codes"
}
},
{
"model": "permission.permission",
"pk": 232,
"fields": {
"model": [
"food",
"qrcode"
],
"query": "{\"food_container__is_active\": true}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir tous les QR codes actifs"
}
},
{
"model": "permission.permission",
"pk": 233,
"fields": {
"model": [
"food",
"qrcode"
],
"query": "{\"food_container__owner\": [\"club\"], \"food_container__is_active\": true}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir tous les QR codes actifs de son club"
}
},
{
"model": "permission.permission",
"pk" : 234,
"fields": {
"model": [
"food",
"transformedfood"
],
"query": "{\"owner\": [\"club\"], \"is_active\": true}",
"type": "change",
"mask": 3,
"field": "ingredients",
"permanent": false,
"description": "Changer les ingrédients d'un plat actif de son club"
}
},
{
"model": "permission.permission",
"pk": 235,
"fields": {
"model": [
"food",
"food"
],
"query": "{}",
"query": "{\"end_of_life\": \"\"}",
"type": "view",
"mask": 3,
"field": "",
"mask": 1,
"permanent": false,
"description": "Voir bouffe"
}
},
{
"model": "permission.permission",
"pk": 236,
"fields": {
"model": [
"food",
"food"
],
"query": "{\"is_active\": true}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir bouffe active"
}
},
{
"model": "permission.permission",
"pk": 237,
"fields": {
"model": [
"food",
"food"
],
"query": "{\"is_active\": true, \"owner\": [\"club\"]}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir bouffe active de son club"
}
},
{
"model": "permission.permission",
"pk": 238,
"fields": {
"model": [
"food",
"food"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"permanent": false,
"description": "Modifier bouffe"
"description": "Voir la bouffe servie"
}
},
{
@ -3815,7 +3547,7 @@
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une transaction vers la note d'un club"
"description": "Créer une transaction vers la note d'un club tant que la source reste au dessus de -20 €"
}
},
{
@ -4186,6 +3918,86 @@
"description": "Voir la note d'un club enfant"
}
},
{
"model": "permission.permission",
"pk": 266,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source_alias\": \"Carte bancaire\"}, {\"source_alias\": \"Espèces\"}, {\"source_alias\": \"Chèque\"}, {\"source_alias\": \"Virement bancaire\"}]",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les transactions de rechargement"
}
},
{
"model": "permission.permission",
"pk": 267,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source_alias\": \"Carte bancaire\"}, {\"source_alias\": \"Espèces\"}, {\"source_alias\": \"Chèque\"}, {\"source_alias\": \"Virement bancaire\"}]",
"type": "change",
"mask": 2,
"field": "valid",
"permanent": false,
"description": "Mettre à jour le statut de validation d'une transaction de rechargement"
}
},
{
"model": "permission.permission",
"pk": 268,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source_alias\": \"Carte bancaire\"}, {\"source_alias\": \"Espèces\"}, {\"source_alias\": \"Chèque\"}, {\"source_alias\": \"Virement bancaire\"}]",
"type": "change",
"mask": 2,
"field": "invalidity_reason",
"permanent": false,
"description": "Modifier la raison d'invalidité d'une transaction de rechargement"
}
},
{
"model": "permission.permission",
"pk": 269,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source_alias\": \"Carte bancaire\"}, {\"source_alias\": \"Espèces\"}, {\"source_alias\": \"Chèque\"}, {\"source_alias\": \"Virement bancaire\"}]",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une transaction de rechargement"
}
},
{
"model": "permission.permission",
"pk": 270,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"AND\", [\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}], [\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}}, {\"valid\": false}]]",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une transaction de ou vers la note d'un club tant que la source reste au dessus de -50 €"
}
},
{
"model": "permission.role",
"pk": 1,
@ -4279,7 +4091,8 @@
158,
159,
160,
213
212,
222
]
}
},
@ -4320,16 +4133,11 @@
50,
141,
169,
212,
214,
215,
217,
218,
219,
222,
224,
227,
233,
234,
237,
220,
221,
247,
258,
259
@ -4510,21 +4318,7 @@
166,
167,
168,
182,
212,
214,
215,
218,
221,
224,
226,
227,
228,
229,
230,
232,
234,
236
182
]
}
},
@ -4732,8 +4526,7 @@
168,
176,
177,
197,
211
197
]
}
},
@ -4761,15 +4554,11 @@
"permissions": [
137,
211,
212,
213,
214,
216,
217,
220,
223,
225,
231,
235,
238
215,
216
]
}
},

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

View File

@ -38,7 +38,7 @@ class Command(BaseCommand):
required=False,
help="""User will have their(s) wrapped generated,
all = all users
adh = all users who have a valid memberships to BDE during the BDE considered
adh = all users who have a valid cd memberships to BDE during the BDE considered
supersuser = all superusers
custom user1,user2,... = a list of username,
custom_id id1,id2,... = a list of user id""",
@ -70,15 +70,7 @@ class Command(BaseCommand):
dest='create',
)
def handle(self, *args, **options):
# useful string for output
red = '\033[31;1m'
yellow = '\033[33;1m'
green = '\033[32;1m'
abort = red + 'ABORT'
warning = yellow + 'WARNING'
success = green + 'SUCCESS'
def handle(self, *args, **options): # NOQA
# Traitement des paramètres
verb = options['verbosity']
bde = []
@ -89,11 +81,11 @@ class Command(BaseCommand):
if options['bde_id']:
if bde:
if verb >= 1:
print(warning)
print(yellow + 'You already defined bde with their name !')
self.stdout.write(self.style.WARNING(
"WARNING\nYou already defined bde with their name !"))
if verb >= 0:
print(abort)
return
self.stdout.write(self.style.ERROR("ABORT"))
exit(1)
bde_id = options['bde_id'].split(',')
bde = [Bde.objects.get(pk=i) for i in bde_id]
@ -113,11 +105,11 @@ class Command(BaseCommand):
user = ['custom_id', [User.objects.get(pk=u) for u in user_id]]
else:
if verb >= 1:
print(warning)
print(yellow + 'You user option is not recognized')
self.sdtout.write(self.style.WARNING(
"WARNING\nYou user option is not recognized"))
if verb >= 0:
print(abort)
return
self.stdout.write(self.style.ERROR("ABORT"))
exit(1)
club = []
if options['club']:
@ -133,11 +125,11 @@ class Command(BaseCommand):
club = ['custom_id', [Club.objects.get(pk=c) for c in club_id]]
else:
if verb >= 1:
print(warning)
print(yellow + 'You club option is not recognized')
self.stdout.write(self.style.WARNING(
"WARNING\nYou club option is not recognized"))
if verb >= 0:
print(abort)
return
self.stdout.write(self.style.ERROR("ABORT"))
exit(1)
change = options['change']
create = options['create']
@ -145,72 +137,75 @@ class Command(BaseCommand):
# check if parameters are sufficient for generate wrapped with the desired option
if not bde:
if verb >= 1:
print(warning)
print(yellow + 'You have not selectionned a BDE !')
self.stdout.write(self.style.WARNING(
"WARNING\nYou have not selectionned a BDE !"))
if verb >= 0:
print(abort)
return
self.stdout.write(self.style.ERROR("ABORT"))
exit(1)
if not (user or club):
if verb >= 1:
print(warning)
print(yellow + 'No club or user selected !')
self.stdout.write(self.style.WARNING(
"WARNING\nNo club or user selected !"))
if verb >= 0:
print(abort)
return
self.stdout.write(self.style.ERROR("ABORT"))
exit(1)
if verb >= 3:
print('\033[1mOptions:\033[m')
self.stdout.write("Options:")
bde_str = ''
for b in bde:
bde_str += str(b)
print('BDE: ' + bde_str)
bde_str += str(b) + '\n'
self.stdout.write("BDE: " + bde_str)
if user:
print('User: ' + user[0])
self.stdout.write('User: ' + user[0])
if club:
print('Club: ' + club[0])
print('change: ' + str(change))
print('create: ' + str(create))
print('')
self.stdout.write('Club: ' + club[0])
self.stdout.write('change: ' + str(change))
self.stdout.write('create: ' + str(create) + '\n')
if not (change or create):
if verb >= 1:
print(warning)
print(yellow + 'change and create is set to false, none wrapped will be created')
self.stdout.write(self.style.WARNING(
"WARNING\nchange and create is set to false, none wrapped will be created"))
if verb >= 0:
print(abort)
return
self.stdout.write(self.style.ERROR("ABORT"))
exit(1)
if verb >= 1 and change:
print(warning)
print(yellow + 'change is set to true, some wrapped may be replaced !')
self.stdout.write(self.style.WARNING(
"WARNING\nchange is set to true, some wrapped may be replaced !"))
if verb >= 1 and not create:
print(warning)
print(yellow + 'create is set to false, wrapped will not be created !')
self.stdout.write(self.style.WARNING(
"WARNING\ncreate is set to false, wrapped will not be created !"))
if verb >= 3 or change or not create:
a = str(input('\033[mContinue ? (y/n) ')).lower()
if a in ['n', 'no', 'non', '0']:
if verb >= 0:
print(abort)
return
self.stdout.write(self.style.ERROR("ABORT"))
exit(1)
note = self.convert_to_note(change, create, bde=bde, user=user, club=club, verb=verb)
if verb >= 1:
print("\033[32mUser and/or Club given has successfully convert in their note\033[m")
self.stdout.write(self.style.SUCCESS(
"User and/or Club given has successfully convert in their note"))
global_data = self.global_data(bde, verb=verb)
if verb >= 1:
print("\033[32mGlobal data has been successfully generated\033[m")
self.stdout.write(self.style.SUCCESS(
"Global data has been successfully generated"))
unique_data = self.unique_data(bde, note, global_data=global_data, verb=verb)
if verb >= 1:
print("\033[32mUnique data has been successfully generated\033[m")
self.stdout.write(self.style.SUCCESS(
"Unique data has been successfully generated"))
self.make_wrapped(unique_data, note, bde, change, create, verb=verb)
if verb >= 1:
print(green + "The wrapped has been generated !")
self.stdout.write(self.style.SUCCESS(
"The wrapped has been generated !"))
if verb >= 0:
print(success)
self.stdout.write(self.style.SUCCESS("SUCCESS"))
exit(0)
return
def convert_to_note(self, change, create, bde=None, user=None, club=None, verb=1):
def convert_to_note(self, change, create, bde=None, user=None, club=None, verb=1): # NOQA
notes = []
for b in bde:
note_for_bde = Note.objects.filter(pk__lte=-1)
@ -253,17 +248,17 @@ class Command(BaseCommand):
note_for_bde = self.filter_note(b, note_for_bde, change, create, verb=verb)
notes.append(note_for_bde)
if verb >= 2:
print("\033[m{nb} note selectionned for bde {bde}".format(nb=len(note_for_bde), bde=b.name))
self.stdout.write(f"{len(note_for_bde)} note selectionned for bde {b.name}")
return notes
def global_data(self, bde, verb=1):
def global_data(self, bde, verb=1): # NOQA
data = {}
for b in bde:
if b.name == 'Rave Part[list]':
if verb >= 2:
print("Begin to make global data")
self.stdout.write("Begin to make global data")
if verb >= 3:
print('nb_transaction')
self.stdout.write("nb_transaction")
# nb total de transactions
data['nb_transaction'] = Transaction.objects.filter(
created_at__gte=b.date_start,
@ -271,7 +266,7 @@ class Command(BaseCommand):
valid=True).count()
if verb >= 3:
print('nb_vieux_con')
self.stdout.write("nb_vieux_con")
# nb total de vielleux con·ne·s derrière le bar
button_id = [2884, 2585]
transactions = Transaction.objects.filter(
@ -286,7 +281,7 @@ class Command(BaseCommand):
data['nb_vieux_con'] = q
if verb >= 3:
print('nb_soiree')
self.stdout.write("nb_soiree")
# nb total de soirée
a_type_id = [1, 2, 4, 5, 7, 10]
data['nb_soiree'] = Activity.objects.filter(
@ -296,7 +291,7 @@ class Command(BaseCommand):
activity_type__pk__in=a_type_id).count()
if verb >= 3:
print('pots, nb_entree_pot')
self.stdout.write('pots, nb_entree_pot')
# nb d'entrée totale aux pots
pot_id = [1, 4, 10]
pots = Activity.objects.filter(
@ -310,7 +305,7 @@ class Command(BaseCommand):
data['nb_entree_pot'] += Entry.objects.filter(activity=pot).count()
if verb >= 3:
print('top3_buttons')
self.stdout.write('top3_buttons')
# top 3 des boutons les plus cliqués
transactions = Transaction.objects.filter(
created_at__gte=b.date_start,
@ -329,7 +324,7 @@ class Command(BaseCommand):
data['top3_buttons'] = list(sorted(d.items(), key=lambda item: item[1], reverse=True))[:3]
if verb >= 3:
print('class_conso_all')
self.stdout.write('class_conso_all')
# le classement des plus gros consommateurs (BDE + club)
transactions = Transaction.objects.filter(
created_at__gte=b.date_start,
@ -348,7 +343,7 @@ class Command(BaseCommand):
data['class_conso_all'] = dict(sorted(d.items(), key=lambda item: item[1], reverse=True))
if verb >= 3:
print('class_conso_bde')
self.stdout.write('class_conso_bde')
# le classement des plus gros consommateurs BDE
transactions = Transaction.objects.filter(
created_at__gte=b.date_start,
@ -368,11 +363,10 @@ class Command(BaseCommand):
else:
# make your wrapped or reuse previous wrapped
raise NotImplementedError("The BDE: {bde_name} has not personalized wrapped, make it !"
.format(bde_name=b.name))
raise NotImplementedError(f"The BDE: {b.name} has not personalized wrapped, make it !")
return data
def unique_data(self, bde, note, global_data=None, verb=1):
def unique_data(self, bde, note, global_data=None, verb=1): # NOQA
data = []
for i in range(len(bde)):
data_bde = []
@ -380,8 +374,7 @@ class Command(BaseCommand):
if verb >= 3:
total = len(note[i])
current = 0
print('Make {nb} data for wrapped sponsored by {bde}'
.format(nb=total, bde=bde[i].name))
self.stdout.write(f"Make {total} data for wrapped sponsored by {bde[i].name}")
for n in note[i]:
d = {}
if 'user' in n.__dir__():
@ -542,12 +535,11 @@ class Command(BaseCommand):
data_bde.append(json.dumps(d))
if verb >= 3:
current += 1
print('\033[2K' + '({c}/{t})'.format(c=current, t=total) + '\033[1A')
self.stdout.write("\033[2K" + f"({current}/{total})" + "\033[1A")
else:
# make your wrapped or reuse previous wrapped
raise NotImplementedError("The BDE: {bde_name} has not personalized wrapped, make it !"
.format(bde_name=bde[i].name))
raise NotImplementedError(f"The BDE: {bde[i].name} has not personalized wrapped, make it !")
data.append(data_bde)
return data
@ -557,7 +549,7 @@ class Command(BaseCommand):
total = 0
for n in note:
total += len(n)
print('\033[mMake {nb} wrapped'.format(nb=total))
self.stdout.write(f"Make {total} wrapped")
for i in range(len(bde)):
for j in range(len(note[i])):
if create and not Wrapped.objects.filter(bde=bde[i], note=note[i][j]):
@ -572,7 +564,7 @@ class Command(BaseCommand):
w.save()
if verb >= 3:
current += 1
print('\033[2K' + '({c}/{t})'.format(c=current, t=total) + '\033[1A')
self.stdout.write("\033[2K" + f"({current}/{total})" + "\033[1A")
return
def filter_note(self, bde, note, change, create, verb=1):

View File

@ -23,9 +23,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
let d1 = document.getElementById("consumer");
let d2 = document.getElementById("creditor");
if (con) { d1.textContent = {{ big_consumer | safe }}[0] + " " + gettext("with") + " " + {{ big_consumer | safe}}[1] + "€";}
else { d1.textContent = gettext("Infortunately, you doesn't have consumer this year");};
else { d1.textContent = gettext("{% trans "Infortunately, you doesn't have consumer this year" %}");};
if (cre) { d2.textContent = {{ big_creancier | safe}}[0] + " " + gettext("with") + " " + {{ big_creancier | safe}}[1] + "€";}
else { d2.textContent = gettext("Congratulations you are a real rat !"); };
else { d2.textContent = gettext("{% trans "Congratulations you are a real rat !" %}"); };
</script>
{% endblock %}

View File

@ -6,17 +6,24 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card card-border shadow">
<div class="card-header text-center">
<h5> {{ title }}</h5>
</div>
<div class="card-body px-0 py-0" id="wrapped_table">
{% render_table table %}
</div>
</div>
</div>
<div id="wrapped_tables">
{% if tables|length > 0 %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "My wrapped" %}
</h3>
{% render_table tables.1 %}
</div>
{% endif %}
{% if tables|length > 0 %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Public wrapped" %}
</h3>
{% render_table tables.0 %}
</div>
{% endif %}
</div>
{% endblock %}
@ -25,7 +32,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
let club_not_public = {{ club_not_public }};
if (club_not_public) { (addMsg("{% trans "Do not forget to ask permission to people who are in your wrapped before to make them public" %}", 'warning'));}
function refreshTable() {
$("#wrapped_table").load(location.pathname + " #wrapped_table");
$("#wrapped_tables").load(location.pathname + " #wrapped_tables");
}
function copylink(id) {

View File

View File

@ -0,0 +1,91 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
from api.tests import TestAPI
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from ..api.views import WrappedViewSet, BdeViewSet
from ..models import Bde, Wrapped
class TestWrapped(TestCase):
"""
Test activities
"""
fixtures = ('initial',)
def setUp(self):
self.user = User.objects.create_superuser(
username="admintoto",
password="tototototo",
email="toto@example.com"
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.bde = Bde.objects.create(
name="The best BDE",
date_start=timezone.now() - timedelta(days=365),
date_end=timezone.now(),
)
self.wrapped = Wrapped.objects.create(
generated=True,
public=False,
bde=self.bde,
note=self.user.note,
data_json="{}",
)
def test_wrapped_list(self):
"""
Display the list of all wrapped
"""
response = self.client.get(reverse("wrapped:wrapped_list"))
self.assertEqual(response.status_code, 200)
def test_wrapped_detail(self):
"""
Display the detail of an wrapped
"""
response = self.client.get(reverse("wrapped:wrapped_detail", args=(self.wrapped.pk,)))
self.assertEqual(response.status_code, 200)
class TestWrappedAPI(TestAPI):
def setUp(self) -> None:
super().setUp()
self.bde = Bde.objects.create(
name="The best BDE",
date_start=timezone.now() - timedelta(days=365),
date_end=timezone.now(),
)
self.wrapped = Wrapped.objects.create(
generated=True,
public=False,
bde=self.bde,
note=self.user.note,
data_json="{}",
)
def test_bde_api(self):
"""
Load Bde API page and test all filters and permissions
"""
self.check_viewset(BdeViewSet, "/api/wrapped/bde/")
def test_wrapped_api(self):
"""
Load Wrapped API page and test all filters and permissions
"""
self.check_viewset(WrappedViewSet, "/api/wrapped/wrapped/")

View File

@ -6,7 +6,8 @@ import json
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView
from django_tables2.views import SingleTableView
from django.views.generic.list import ListView
from django_tables2.views import MultiTableMixin
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin
@ -14,21 +15,29 @@ from .models import Wrapped
from .tables import WrappedTable
class WrappedListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
class WrappedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
Display all Wrapped, and classify by year
"""
model = Wrapped
table_class = WrappedTable
tables = [
lambda data: WrappedTable(data, prefix="public-"),
lambda data: WrappedTable(data, prefix="personnal-"),
]
template_name = 'wrapped/wrapped_list.html'
extra_context = {'title': _("List of wrapped")}
def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct()
def get_table_data(self):
return Wrapped.objects.filter(PermissionBackend.filter_queryset(
self.request, Wrapped, "change", field='public')).distinct().order_by("-bde__date_start")
def get_tables_data(self):
return [
Wrapped.objects.filter(public=True),
Wrapped.objects
.filter(PermissionBackend.filter_queryset(self.request, Wrapped, "change", field='public'))
.distinct()
.order_by("-bde__date_start")
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

118
docs/_static/img/graphs/wrapped.svg vendored Normal file
View File

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: model_graph Pages: 1 -->
<svg width="319pt" height="245pt"
viewBox="0.00 0.00 319.00 245.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 241)">
<title>model_graph</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-241 315,-241 315,4 -4,4"/>
<!-- wrapped_models_Bde -->
<g id="node1" class="node">
<title>wrapped_models_Bde</title>
<polygon fill="white" stroke="transparent" points="8,-4 8,-79 158,-79 158,-4 8,-4"/>
<polygon fill="#1b563f" stroke="transparent" points="9,-56.5 9,-77.5 157,-77.5 157,-56.5 9,-56.5"/>
<text text-anchor="start" x="52" y="-65.5" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="62" y="-65.5" font-family="Roboto" font-weight="bold" font-size="10.00" fill="white"> &#160;&#160;&#160;Bde &#160;&#160;&#160;</text>
<text text-anchor="start" x="11" y="-49.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="21" y="-49.1" font-family="Roboto" font-weight="bold" font-size="8.00">id</text>
<text text-anchor="start" x="31" y="-49.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="77" y="-49.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="87" y="-49.1" font-family="Roboto" font-weight="bold" font-size="8.00">AutoField</text>
<text text-anchor="start" x="131" y="-49.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="11" y="-36.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="21" y="-36.1" font-family="Roboto" font-size="8.00">date_end</text>
<text text-anchor="start" x="60" y="-36.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="77" y="-36.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="87" y="-36.1" font-family="Roboto" font-size="8.00">DateTimeField</text>
<text text-anchor="start" x="145" y="-36.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="11" y="-23.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="21" y="-23.1" font-family="Roboto" font-size="8.00">date_start</text>
<text text-anchor="start" x="63" y="-23.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="77" y="-23.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="87" y="-23.1" font-family="Roboto" font-size="8.00">DateTimeField</text>
<text text-anchor="start" x="145" y="-23.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="11" y="-10.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="21" y="-10.1" font-family="Roboto" font-size="8.00">name</text>
<text text-anchor="start" x="45" y="-10.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="77" y="-10.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="87" y="-10.1" font-family="Roboto" font-size="8.00">CharField</text>
<text text-anchor="start" x="125" y="-10.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<polygon fill="none" stroke="black" points="8,-4 8,-79 158,-79 158,-4 8,-4"/>
</g>
<!-- wrapped_models_Wrapped -->
<g id="node2" class="node">
<title>wrapped_models_Wrapped</title>
<polygon fill="white" stroke="transparent" points="67,-132 67,-233 231,-233 231,-132 67,-132"/>
<polygon fill="#1b563f" stroke="transparent" points="68,-210.5 68,-231.5 230,-231.5 230,-210.5 68,-210.5"/>
<text text-anchor="start" x="103" y="-219.5" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="113" y="-219.5" font-family="Roboto" font-weight="bold" font-size="10.00" fill="white"> &#160;&#160;&#160;Wrapped &#160;&#160;&#160;</text>
<text text-anchor="start" x="70" y="-203.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="80" y="-203.1" font-family="Roboto" font-weight="bold" font-size="8.00">id</text>
<text text-anchor="start" x="90" y="-203.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="137" y="-203.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="147" y="-203.1" font-family="Roboto" font-weight="bold" font-size="8.00">AutoField</text>
<text text-anchor="start" x="191" y="-203.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="70" y="-190.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="80" y="-190.1" font-family="Roboto" font-weight="bold" font-size="8.00">bde</text>
<text text-anchor="start" x="98" y="-190.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="137" y="-190.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="147" y="-190.1" font-family="Roboto" font-weight="bold" font-size="8.00">ForeignKey (id)</text>
<text text-anchor="start" x="218" y="-190.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="70" y="-177.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="80" y="-177.1" font-family="Roboto" font-weight="bold" font-size="8.00">note</text>
<text text-anchor="start" x="101" y="-177.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="137" y="-177.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="147" y="-177.1" font-family="Roboto" font-weight="bold" font-size="8.00">ForeignKey (id)</text>
<text text-anchor="start" x="218" y="-177.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="70" y="-164.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="80" y="-164.1" font-family="Roboto" font-size="8.00">data_json</text>
<text text-anchor="start" x="120" y="-164.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="137" y="-164.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="147" y="-164.1" font-family="Roboto" font-size="8.00">TextField</text>
<text text-anchor="start" x="182" y="-164.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="70" y="-151.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="80" y="-151.1" font-family="Roboto" font-size="8.00">generated</text>
<text text-anchor="start" x="123" y="-151.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="137" y="-151.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="147" y="-151.1" font-family="Roboto" font-size="8.00">BooleanField</text>
<text text-anchor="start" x="200" y="-151.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="70" y="-138.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="80" y="-138.1" font-family="Roboto" font-size="8.00">public</text>
<text text-anchor="start" x="105" y="-138.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="137" y="-138.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="147" y="-138.1" font-family="Roboto" font-size="8.00">BooleanField</text>
<text text-anchor="start" x="200" y="-138.1" font-family="Roboto" font-size="8.00"> &#160;&#160;&#160;</text>
<polygon fill="none" stroke="black" points="67,-132 67,-233 231,-233 231,-132 67,-132"/>
</g>
<!-- wrapped_models_Wrapped&#45;&gt;wrapped_models_Bde -->
<g id="edge1" class="edge">
<title>wrapped_models_Wrapped&#45;&gt;wrapped_models_Bde</title>
<path fill="none" stroke="black" d="M119.99,-120.4C114,-107.79 107.84,-94.82 102.31,-83.16"/>
<ellipse fill="black" stroke="black" cx="121.77" cy="-124.15" rx="4" ry="4"/>
<text text-anchor="middle" x="132" y="-103.6" font-family="Roboto" font-size="8.00"> bde (+)</text>
</g>
<!-- note_models_notes_Note -->
<g id="node3" class="node">
<title>note_models_notes_Note</title>
<polygon fill="white" stroke="transparent" points="192,-31 192,-52 240,-52 240,-31 192,-31"/>
<polygon fill="#1b563f" stroke="transparent" points="192,-30.5 192,-51.5 240,-51.5 240,-30.5 192,-30.5"/>
<text text-anchor="start" x="196.5" y="-38.9" font-family="Roboto" font-size="8.00"> &#160;</text>
<text text-anchor="start" x="201.5" y="-38.9" font-family="Roboto" font-size="12.00" fill="white">Note</text>
<text text-anchor="start" x="230.5" y="-38.9" font-family="Roboto" font-size="8.00"> &#160;</text>
</g>
<!-- wrapped_models_Wrapped&#45;&gt;note_models_notes_Note -->
<g id="edge2" class="edge">
<title>wrapped_models_Wrapped&#45;&gt;note_models_notes_Note</title>
<path fill="none" stroke="black" d="M178.48,-120.33C189.12,-98.27 200.3,-75.07 207.66,-59.8"/>
<ellipse fill="black" stroke="black" cx="176.64" cy="-124.16" rx="4" ry="4"/>
<text text-anchor="middle" x="204.5" y="-103.6" font-family="Roboto" font-size="8.00"> note (+)</text>
</g>
<!-- \n\n\n -->
<g id="node4" class="node">
<title>\n\n\n</title>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@ -55,6 +55,7 @@ Les adhérent⋅es ont la possibilité d'inviter des ami⋅es. Pour cela, les di
* Activité concernée (clé étrangère)
* Nom de famille
* Prénom
* École
* Note de la personne ayant invité
Certaines contraintes s'appliquent :

View File

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

View File

@ -12,8 +12,10 @@ Applications de la Note Kfet 2020
../api/index
registration
logs
food
treasury
wei
wrapped
La Note Kfet 2020 est un projet Django, décomposé en applications.
Certaines applications sont développées uniquement pour ce projet, et sont indispensables,
@ -65,8 +67,12 @@ Applications facultatives
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
* `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc...
* `Food <food>`_ :
Gestion de la nourriture dans Kfet pour les clubs.
* `Treasury <treasury>`_ :
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques...
* `WEI <wei>`_ :
Interface de gestion du WEI.
* `Wrapped <wrapped>`_ :
Récapitulatif personnalisé annuel de statitiques globales et personnelles.

108
docs/apps/wrapped.rst Normal file
View File

@ -0,0 +1,108 @@
Wrapped
=======
Cette application montre les statistiques annuelles des utilisateur·ice·s et/ou des clubs.
Modèles
-------
Bde
~~~
Le modèle ``Bde`` contient des informations relatifs à un BDE :
* ``name`` : ``CharField``, nom du BDE.
* ``date_start`` : ``DateField``, date de prise de fonction du bureau BDE considéré.
* ``date_end`` : ``DateField``, date de démission du bureau BDE considéré.
Wrapped
~~~~~~~
Contient les informations sur un wrapped :
* ``generated`` : ``BooleanField``, indique si le wrapped a été généré ou non.
* ``public`` : ``BooleanField``, indique si le wrapped est visible de tous les utilisateur·ice·s ou non.
* ``bde`` : ``ForeignKey(Bde)``, BDE auquel le wrapped correspond.
* ``note`` : ``ForeignKey(Note)``, note à laquelle le wrapped correspond.
* ``data_json`` : ``TextField``, diverses statistique concernant les notes durant le mandat BDE
considéré ou sur la NoteKfet dans sa globalité.
Graphe des modèles
~~~~~~~~~~~~~~~~~~
.. image:: ../_static/img/graphs/wrapped.svg
:width: 960
:alt: Graphe des modèles de l'application Wrapped
Fonctionnement
--------------
Création d'un BDE
~~~~~~~~~~~~~~~~~
Seul un⋅e respo info peut créer un BDE. Pour cela, se rendre dans l'onglet « Admin »., puis « BDE » et
enfin « + Ajouter BDE ». Iel doit renseigner, les dates de début et de fin du bureau BDE ainsi que le
nom de la liste.
Génération des wrappeds
~~~~~~~~~~~~~~~~~~~~~~~
Seul un·e respo info peut générer des wrappeds. Pour une utilisation annuelle classique, iel exécute la
commande :
``./manage.py generate_wrapped -b "bde_name" -u adh -c active``
Pour une utilisation plus technique de cette commande se référer à sa documentation
``./manage.py help generate_wrapped``
Le script prend une dizaine de minutes pour générer tous les wrappeds.
Créer ses propres wrappeds
--------------------------
Cette section est plus technique et s'addresse plutôt à des respos infos en cours de mandat qui voudrai
faire les wrappeds de leur propre BDE.
Contenu
~~~~~~~
Il est fortement conseillé de bien réfléchir à ce que l'on souhaite mettre sur un wrapped, plusieurs
critères sont à prendre compte :
* compréhension, est-ce que la donnée fait sens auprès des utilisateur·ice·s.
* pertinence, est-ce que la donnée fonctionne pour un grand nombre d'utilisateur.
* faisabilité, est-ce que le temps de calcul est suffisament rapide.
* complexité, est-ce que c'est trop compliqué à coder.
Script
~~~~~~
Le script *generate_wrapped* fonctionne de la manière suivante :
* ``convert_to_note`` : en fonction des arguments d'entrée, il récupére toutes les notes dont le·s
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).
* ``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.
* ``make_wrapped`` : enfin, le cas échéant, pour chaque bde, et pour chaque note, le wrapped est crée
ou modifié, et enregistré, s'il est crée il est par défault non public.
Seules les fonctions ``global_data`` et ``unique_data`` sont à modifier, pour implementer un nouveau
BDE.
Template
~~~~~~~~
Il y a au moins deux templates a écrire pour chaque bde :
* ``templates/wrapped/{bde_id}/wrapped_view_club.html``: le template pour les wrappeds des clubs
* ``templates/wrapped/{bde_id}/wrapped_view_user.html``: le template pour les wrappeds des
utilisateur·ice·s
Il est conseillé de suivre la même arborescence pour les fichiers statics (fonts personnalisées,
images, css, etc). De même, il est conseillé de créé un fichier
``templates/wrapped/{bde_id}/wrapped_base.html`` et d'étendre cette template.

View File

@ -43,6 +43,11 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
'PKCE_REQUIRED': False,
'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``,
@ -57,6 +62,14 @@ On ajoute enfin les routes dans ``urls.py`` :
path('o/', include('oauth2_provider.urls', namespace='oauth2_provider'))
)
Enfin pour utiliser OIDC, il faut générer une clé privé que l'on va, par défaut,
mettre dans `/var/secrets/oidc.key` :
.. code:: bash
cd /var/secrets/
openssl genrsa -out oidc.key 4096
L'OAuth2 est désormais prêt à être utilisé.

View File

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

View File

@ -227,6 +227,22 @@ En production, ce fichier contient :
)
Génération d'une clé privé pour OIDC
------------------------------------
Pour pouvoir proposer le service de connexion Openid Connect (OIDC) par OAuth2, il y a
besoin d'une clé privé. Par défaut, elle est cherché dans le fichier `/var/secrets/oidc.key`
(sinon, il faut modifier l'emplacement dans les fichiers de configurations).
Pour générer la clé, il faut aller dans le dossier `/var/secrets` (à créer, si nécessaire) puis
utiliser la commande de génération :
.. code:: bash
cd /var/secrets
openssl genrsa -out oidc.key 4096
Configuration des tâches récurrentes
------------------------------------

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -268,6 +268,10 @@ OAUTH2_PROVIDER = {
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'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_RSA_PRIVATE_KEY':
os.getenv('OIDC_RSA_PRIVATE_KEY', '/var/secrets/oidc.key'),
'SCOPES': { 'openid': "OpenID Connect scope" },
}
# Take control on how widget templates are sourced

View File

@ -61,6 +61,11 @@ mark {
/* Make navbar more readable */
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, .75);
text-shadow: 2px 2px 15px #ffeb40;
}
.navbar-brand {
text-shadow: 2px 2px 15px #ffeb40;
}
/* Last BDE colors */
@ -68,7 +73,8 @@ mark {
/* background-color: rgb(18, 67, 4) !important; */
/* MODE VIEUXCON=ON */
/* background-color: rgb(166, 0, 2) !important; */
background-color: rgb(100, 30, 100) !important;
background-color: rgb(0, 0, 0);
background-image: url('/static/img/rp_bg.png');
}
html {
@ -83,81 +89,94 @@ body {
.btn-outline-primary:hover,
.btn-outline-primary:not(:disabled):not(.disabled).active,
.btn-outline-primary:not(:disabled):not(.disabled):active {
color: rgb(240, 200, 240);
background-color: rgb(30, 120, 150);
border-color: rgb(190, 150, 190);
color: rgb(0, 0, 0);
background-color: rgb(255, 0, 101);
border-color: rgb(255, 203, 32);
}
.btn-outline-primary {
color: #a2a;
background-color: #6bc;
border-color: #719;
color: #000;
background-color: #ffcb20;
border-color: #000;
}
.turbolinks-progress-bar {
background-color: #12342E;
background-color: #ffffff;
}
.btn-primary:hover,
.btn-primary:not(:disabled):not(.disabled).active,
.btn-primary:not(:disabled):not(.disabled):active {
color: rgb(150, 200, 240);
background-color: rgb(50, 100, 140);
border-color: rgb(0, 0, 0);
color: rgb(0, 0, 0);
background-color: rgb(255, 0, 101);
border-color: rgb(255, 203, 32);
}
.btn-primary {
color: #eae;
background-color: #616;
border-color: #000000;
color: #ffcb20;
background-color: #000000;
border-color: #ffcd20;
}
.border-primary {
border-color: rgb(222, 180, 222) !important;
border-color: rgb(255, 255, 255) !important;
}
.btn-secondary {
color: #eae;
background-color: #616;
border-color: #000000;
color: #ff0065;
background-color: #000000;
border-color: #ff0065;
}
.btn-secondary:hover,
.btn-secondary:not(:disabled):not(.disabled).active,
.btn-secondary:not(:disabled):not(.disabled):active {
color: rgb(150, 200, 240);
background-color: rgb(50, 100, 140);
border-color: rgb(0, 0, 0);
color: rgb(0, 0, 0);
background-color: rgb(255, 203, 32);
border-color: rgb(255, 0, 101);
}
.btn-outline-dark-shiny {
background-color: #222;
border-color: #61605b;
color: rgba(255, 0, 101, 75%);
}
.btn-outline-dark-shiny:hover,
.btn-outline-dark-shiny:not(:disabled):not(.disabled).active,
.btn-outline-dark-shiny:not(:disabled):not(.disabled):active {
color: rgb(0, 0, 0);
background-color: rgb(255, 203, 32);
border-color: rgb(255, 0, 101);
}
.btn-outline-dark {
color: #000000;
border-color: #000000;
background-color: #222;
border-color: #61605b;
color: rgba(255, 203, 32, 75%);
}
.btn-outline-dark:hover,
.btn-outline-dark:not(:disabled):not(.disabled).active,
.btn-outline-dark:not(:disabled):not(.disabled):active {
color: rgb(50, 100, 160);
background-color: rgb(240, 150, 240);
border-color: rgb(50, 100, 160);
color: rgb(0, 0, 0);
background-color: rgb(255, 0, 101);
border-color: rgb(255, 203, 32);
}
a {
color: rgb(0, 150, 150);
color: rgb(255, 0, 101);
}
a:hover {
color: rgb(200, 0, 200);
color: rgb(255, 203, 32);
}
.form-control:focus {
box-shadow: 0 0 0 0.25rem rgb(0 150 150 / 50%);
border-color: rgb(0, 200, 200);
box-shadow: 0 0 0 0.25rem rgb(255 0 101 / 50%);
border-color: rgb(255, 0, 101);
}
.btn-outline-primary.focus {
box-shadow: 0 0 0 0.25rem rgb(0 150 150 / 22%);
box-shadow: 0 0 0 0.25rem rgb(255 203 32 / 22%);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -26,7 +26,7 @@ function afterKonami () {
})
rythm.addRythm('d-flex', 'color', 50, 50, {
from: [64, 64, 64],
to: [128, 64, 128]
to: [255, 0, 101]
})
rythm.addRythm('nav-link', 'jump', 150, 50, {
min: 0,

View File

@ -1,34 +0,0 @@
# This is a workaround meant for use with the nix package manager. If you don't know what it is or don't use it, please ignore this file.
#
# The nk20 javascript static location are hardcoded for imperative system.
# This make ./manage.py collectstatic hard to use with nixos.
#
# A workaround is to enter a FHSUserEnv with the static placed under /share/javascript/<static>.
# This emulate a debian like system and enable collecting static normally with ./manage.py collectstatics.
# The regular shell.nix should be enough for other configurations.
#
# Warning, you are still supposed to use pip package with a venv !
{ pkgs ? import <nixpkgs> {} }:
(pkgs.buildFHSUserEnv {
name = "pipzone";
targetPkgs = pkgs: (with pkgs;
let
fhs-static = stdenv.mkDerivation {
name = "fhs-static";
buildCommand = ''
mkdir -p $out/share/javascript/bootstrap4
mkdir -p $out/share/javascript/jquery
ln -s ${python39Packages.xstatic-bootstrap}/lib/python3.9/site-packages/xstatic/pkg/bootstrap/data/* $out/share/javascript/bootstrap4
ln -s ${python39Packages.xstatic-jquery}/lib/python3.9/site-packages/xstatic/pkg/jquery/data/* $out/share/javascript/jquery
'';
};
in [
fhs-static
python39
gettext
python39Packages.pip
python39Packages.virtualenv
python39Packages.setuptools
]);
runScript = "bash";
}).env

View File

@ -1,23 +0,0 @@
# This is meant for use with the nix package manager. If you don't know what it is or don't use it, please ignore this file.
#
# This shell.nix contains all dependencies require to create a venv and pip install -r requirements.txt.
#
# Please check shell-static.nix for running ./manage.py collectstatics.
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
python39
python39Packages.pip
python39Packages.setuptools
gettext
];
shellHook = ''
# Tells pip to put packages into $PIP_PREFIX instead of the usual locations.
# See https://pip.pypa.io/en/stable/user_guide/#environment-variables.
export PIP_PREFIX=$(pwd)/_build/pip_packages
export PYTHONPATH="$PIP_PREFIX/${pkgs.python39.sitePackages}:$PYTHONPATH"
export PATH="$PIP_PREFIX/bin:$PATH"
unset SOURCE_DATE_EPOCH
'';
}