mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-07-03 22:32:51 +02:00
Compare commits
98 Commits
Add_some_p
...
django-5.2
Author | SHA1 | Date | |
---|---|---|---|
f54dd30482 | |||
7eafe33945 | |||
6edef619aa | |||
8a1f30ebe2 | |||
b2c6b0e85d | |||
1567bc6ce5 | |||
c411197af3 | |||
bc517f02e5 | |||
e83ee8015f | |||
c26534b6b7 | |||
cdc6f0a3f8 | |||
c153d5f10a | |||
3f76ca6472 | |||
5c5f579729 | |||
a6df0e7c69 | |||
763535bea4 | |||
df0d886db9 | |||
092cc37320 | |||
16b55e23af | |||
97621e8704 | |||
cf4c23d1ac | |||
d71105976f | |||
89cc03141b | |||
6822500fdc | |||
63f6528adc | |||
40ac1daece | |||
e617048332 | |||
9eb6edb37d | |||
70a57bf02d | |||
02453e07ba | |||
4479e8f97a | |||
a351415494 | |||
16cfaa809a | |||
f2cd0b6d36 | |||
a2e2ff5fa9 | |||
53d0480a12 | |||
ff812a028c | |||
136f636fda | |||
5a8acbde00 | |||
f60dc8cfa0 | |||
067dd6f9d1 | |||
7b1e32e514 | |||
e88dbfd597 | |||
3d34270959 | |||
3bb99671ec | |||
0d69383dfd | |||
7b9ff119e8 | |||
108a56745c | |||
9643d7652b | |||
fadb289ed7 | |||
905fc6e7cc | |||
cdd81c1444 | |||
4afafceba1 | |||
3065eacc96 | |||
71ef3aedd8 | |||
0cf11c6348 | |||
70abd0f490 | |||
4445dd4a96 | |||
03932672f3 | |||
dc6a40de02 | |||
d58a299a8b | |||
ad0a219ed3 | |||
c4404ef995 | |||
b4f3a158a6 | |||
f0e9a7d3dc | |||
a2b42c5329 | |||
6d6583bfe6 | |||
485d093002 | |||
ff4353d344 | |||
a90f45bd8b | |||
10c22ccc53 | |||
6969cee0f3 | |||
ddeada200b | |||
8e2b24b2da | |||
bd76c280ec | |||
ca0a95ba9e | |||
614f76e699 | |||
a5815f0bc7 | |||
84e9fea15f | |||
b7a660ee40 | |||
b9ebb1718a | |||
7ba5c76a89 | |||
702ddb5679 | |||
93aed87265 | |||
60355196ce | |||
9bffb32a5e | |||
5ef019c5c2 | |||
8da62e62fb | |||
56a43396d4 | |||
cb61c511ce | |||
25bfa575ed | |||
e21d9fcfbe | |||
b293904525 | |||
bd7e6b8ad4 | |||
a208a4fa25 | |||
4799b2c52d | |||
6c63c6417c | |||
4563b2b640 |
@ -21,3 +21,6 @@ EMAIL_PASSWORD=CHANGE_ME
|
||||
# Wiki configuration
|
||||
WIKI_USER=NoteKfet2020
|
||||
WIKI_PASSWORD=
|
||||
|
||||
# OIDC
|
||||
OIDC_RSA_PRIVATE_KEY=CHANGE_ME
|
||||
|
16
README.md
16
README.md
@ -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 copier la clé dans .env dans le champ
|
||||
`OIDC_RSA_PRIVATE_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 le champ
|
||||
`OIDC_RSA_PRIVATE_KEY` dans le .env (par défaut `/var/secrets/oidc.key`).
|
||||
|
||||
8. *Enjoy \o/*
|
||||
|
||||
### Installation avec Docker
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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', ]
|
||||
|
||||
|
||||
|
@ -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,
|
||||
|
18
apps/activity/migrations/0006_guest_school.py
Normal file
18
apps/activity/migrations/0006_guest_school.py
Normal 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,
|
||||
),
|
||||
]
|
19
apps/activity/migrations/0007_alter_guest_activity.py
Normal file
19
apps/activity/migrations/0007_alter_guest_activity.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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,
|
||||
|
@ -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()))
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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(
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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']
|
||||
|
@ -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__'
|
||||
|
@ -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)
|
||||
|
@ -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', ]
|
||||
|
100
apps/food/fixtures/initial.json
Normal file
100
apps/food/fixtures/initial.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
@ -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,
|
||||
)
|
||||
|
@ -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",),
|
||||
),
|
||||
]
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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),
|
||||
]
|
||||
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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()
|
||||
if ('old_allergens' in kwargs
|
||||
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||
self.update_allergens()
|
||||
|
||||
@transaction.atomic
|
||||
def update(self):
|
||||
self.update_allergens()
|
||||
self.update_expiry_date()
|
||||
# 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()))
|
||||
# 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
|
||||
# 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_allergens == list(self.allergens.all()):
|
||||
return
|
||||
super().save()
|
||||
# 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()
|
||||
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
parent.update_allergens()
|
||||
if created:
|
||||
self.expiry_date = self.shelf_life + self.creation_date
|
||||
|
||||
@transaction.atomic
|
||||
def update_expiry_date(self):
|
||||
# When expiry_date is changed, simply update the parents' expiry_date
|
||||
old_expiry_date = self.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)
|
||||
# We save here because we need pk for many-to-many relation
|
||||
super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
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):
|
||||
self.update_allergens()
|
||||
self.update_expiry_date()
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
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)
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
53
apps/food/templates/food/food_detail.html
Normal file
53
apps/food/templates/food/food_detail.html
Normal 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 %}
|
71
apps/food/templates/food/food_list.html
Normal file
71
apps/food/templates/food/food_list.html
Normal 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 %}
|
@ -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 %}
|
116
apps/food/templates/food/manage_ingredients.html
Normal file
116
apps/food/templates/food/manage_ingredients.html
Normal 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 %}
|
52
apps/food/templates/food/qrcode.html
Normal file
52
apps/food/templates/food/qrcode.html
Normal 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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
87
apps/food/templates/food/transformedfood_update.html
Normal file
87
apps/food/templates/food/transformedfood_update.html
Normal 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 %}
|
@ -1,3 +0,0 @@
|
||||
# from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
170
apps/food/tests/test_food.py
Normal file
170
apps/food/tests/test_food.py
Normal 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/')
|
@ -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
53
apps/food/utils.py
Normal 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]
|
@ -1,421 +1,509 @@
|
||||
# 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})
|
||||
| Q(**{f'owner__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)
|
||||
|
||||
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:
|
||||
context['form'].fields[field].initial = getattr(food, field)
|
||||
|
||||
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)
|
||||
else:
|
||||
return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs))
|
||||
|
||||
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 = 100
|
||||
|
||||
|
||||
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)
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
46
apps/member/migrations/0014_create_bda.py
Normal file
46
apps/member/migrations/0014_create_bda.py
Normal file
@ -0,0 +1,46 @@
|
||||
from django.db import migrations
|
||||
|
||||
def create_bda(apps, schema_editor):
|
||||
"""
|
||||
The club BDA is now pre-injected.
|
||||
"""
|
||||
Club = apps.get_model("member", "club")
|
||||
NoteClub = apps.get_model("note", "noteclub")
|
||||
Alias = apps.get_model("note", "alias")
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
|
||||
|
||||
Club.objects.get_or_create(
|
||||
id=10,
|
||||
name="BDA",
|
||||
email="bda.ensparissaclay@gmail.com",
|
||||
require_memberships=True,
|
||||
membership_fee_paid=750,
|
||||
membership_fee_unpaid=750,
|
||||
membership_duration=396,
|
||||
membership_start="2024-08-01",
|
||||
membership_end="2025-09-30",
|
||||
)
|
||||
NoteClub.objects.get_or_create(
|
||||
id=1937,
|
||||
club_id=10,
|
||||
polymorphic_ctype_id=polymorphic_ctype_id,
|
||||
)
|
||||
Alias.objects.get_or_create(
|
||||
id=1937,
|
||||
note_id=1937,
|
||||
name="BDA",
|
||||
normalized_name="bda",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0013_auto_20240801_1436'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_bda),
|
||||
]
|
||||
|
@ -20,12 +20,14 @@ 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>
|
||||
<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">
|
||||
<button type="button" class="btn btn-default" id="js-zoom-in">
|
||||
|
@ -44,7 +44,7 @@ class TemplateLoggedInTests(TestCase):
|
||||
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
|
||||
|
||||
def test_logout(self):
|
||||
response = self.client.get(reverse("logout"))
|
||||
response = self.client.post(reverse("logout"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_admin_index(self):
|
||||
|
@ -13,7 +13,7 @@ def register_note_urls(router, path):
|
||||
router.register(path + '/note', NotePolymorphicViewSet)
|
||||
router.register(path + '/alias', AliasViewSet)
|
||||
router.register(path + '/trust', TrustViewSet)
|
||||
router.register(path + '/consumer', ConsumerViewSet)
|
||||
router.register(path + '/consumer', ConsumerViewSet, basename='alias2')
|
||||
|
||||
router.register(path + '/transaction/category', TemplateCategoryViewSet)
|
||||
router.register(path + '/transaction/transaction', TransactionViewSet)
|
||||
|
@ -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()
|
||||
|
@ -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>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,10 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||
from oauth2_provider.scopes import BaseScopes
|
||||
from member.models import Club
|
||||
from note.models import Alias
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
from .backends import PermissionBackend
|
||||
@ -17,25 +19,46 @@ class PermissionScopes(BaseScopes):
|
||||
"""
|
||||
|
||||
def get_all_scopes(self):
|
||||
return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
||||
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
||||
scopes['openid'] = "OpenID Connect"
|
||||
return scopes
|
||||
|
||||
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
if not application:
|
||||
return []
|
||||
return [f"{p.id}_{p.membership.club.id}"
|
||||
for t in Permission.PERMISSION_TYPES
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for t in Permission.PERMISSION_TYPES
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||
scopes.append('openid')
|
||||
return scopes
|
||||
|
||||
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
if not application:
|
||||
return []
|
||||
return [f"{p.id}_{p.membership.club.id}"
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||
scopes.append('openid')
|
||||
return scopes
|
||||
|
||||
|
||||
class PermissionOAuth2Validator(OAuth2Validator):
|
||||
oidc_claim_scope = None # fix breaking change of django-oauth-toolkit 2.0.0
|
||||
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
|
||||
oidc_claim_scope.update({"name": 'openid',
|
||||
"normalized_name": 'openid',
|
||||
"email": 'openid',
|
||||
})
|
||||
|
||||
def get_additional_claims(self, request):
|
||||
return {
|
||||
"name": request.user.username,
|
||||
"normalized_name": Alias.normalize(request.user.username),
|
||||
"email": request.user.email,
|
||||
}
|
||||
|
||||
def get_discovery_claims(self, request):
|
||||
claims = super().get_discovery_claims(self)
|
||||
return claims + ["name", "normalized_name", "email"]
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
@ -54,6 +77,8 @@ class PermissionOAuth2Validator(OAuth2Validator):
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
request.scopes = valid_scopes
|
||||
if 'openid' in scopes:
|
||||
valid_scopes.add('openid')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
@ -19,6 +19,7 @@ EXCLUDED = [
|
||||
'oauth2_provider.accesstoken',
|
||||
'oauth2_provider.grant',
|
||||
'oauth2_provider.refreshtoken',
|
||||
'oauth2_provider.idtoken',
|
||||
'sessions.session',
|
||||
]
|
||||
|
||||
|
@ -10,7 +10,7 @@ from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from activity.models import Activity
|
||||
from member.models import Club, Membership
|
||||
from note.models import NoteUser
|
||||
from note.models import NoteUser, NoteClub
|
||||
from wei.models import WEIClub, Bus, WEIRegistration
|
||||
|
||||
|
||||
@ -122,10 +122,13 @@ class TestPermissionDenied(TestCase):
|
||||
|
||||
def test_validate_weiregistration(self):
|
||||
wei = WEIClub.objects.create(
|
||||
name="WEI Test",
|
||||
membership_start=date.today(),
|
||||
date_start=date.today() + timedelta(days=1),
|
||||
date_end=date.today() + timedelta(days=1),
|
||||
parent_club=Club.objects.get(name="Kfet"),
|
||||
)
|
||||
NoteClub.objects.create(club=wei)
|
||||
registration = WEIRegistration.objects.create(wei=wei, user=self.user, birth_date="2000-01-01")
|
||||
response = self.client.get(reverse("wei:validate_registration", kwargs=dict(pk=registration.pk)))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
@ -171,7 +171,7 @@ class ScopesView(LoginRequiredMixin, TemplateView):
|
||||
available_scopes = scopes.get_available_scopes(app)
|
||||
context["scopes"][app] = OrderedDict()
|
||||
items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes]
|
||||
items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
|
||||
# items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
|
||||
for k, v in items:
|
||||
context["scopes"][app][k] = v
|
||||
|
||||
|
18
apps/treasury/migrations/0010_alter_invoice_bde.py
Normal file
18
apps/treasury/migrations/0010_alter_invoice_bde.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
|
BIN
apps/treasury/static/img/Diolistos.png
Executable file
BIN
apps/treasury/static/img/Diolistos.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 2.8 MiB |
BIN
apps/treasury/static/img/Diolistos_bg.jpg
Executable file
BIN
apps/treasury/static/img/Diolistos_bg.jpg
Executable file
Binary file not shown.
After Width: | Height: | Size: 284 KiB |
@ -1,10 +1,11 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, WEIMembershipForm, BusForm, BusTeamForm
|
||||
from .registration import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, WEIMembership1AForm, \
|
||||
WEIMembershipForm, BusForm, BusTeamForm
|
||||
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
|
||||
|
||||
__all__ = [
|
||||
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||
'WEIForm', 'WEIRegistrationForm', 'WEIRegistration1AForm', 'WEIRegistration2AForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||
]
|
||||
|
@ -5,7 +5,7 @@ from bootstrap_datepicker_plus.widgets import DatePickerInput
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.forms import CheckboxSelectMultiple, RadioSelect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note.models import NoteSpecial, NoteUser
|
||||
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
|
||||
@ -24,6 +24,7 @@ class WEIForm(forms.ModelForm):
|
||||
"membership_end": DatePickerInput(),
|
||||
"date_start": DatePickerInput(),
|
||||
"date_end": DatePickerInput(),
|
||||
"caution_amount": AmountInput(),
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +40,11 @@ class WEIRegistrationForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = WEIRegistration
|
||||
exclude = ('wei', 'clothing_cut')
|
||||
fields = [
|
||||
'user', 'soge_credit', 'birth_date', 'gender', 'clothing_size',
|
||||
'health_issues', 'emergency_contact_name', 'emergency_contact_phone',
|
||||
'first_year', 'information_json', 'caution_check'
|
||||
]
|
||||
widgets = {
|
||||
"user": Autocomplete(
|
||||
User,
|
||||
@ -49,11 +54,30 @@ class WEIRegistrationForm(forms.ModelForm):
|
||||
'placeholder': 'Nom ...',
|
||||
},
|
||||
),
|
||||
"birth_date": DatePickerInput(options={'minDate': '1900-01-01',
|
||||
'maxDate': '2100-01-01'}),
|
||||
"birth_date": DatePickerInput(options={
|
||||
'minDate': '1900-01-01',
|
||||
'maxDate': '2100-01-01'
|
||||
}),
|
||||
"caution_check": forms.BooleanField(
|
||||
required=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class WEIRegistration2AForm(WEIRegistrationForm):
|
||||
class Meta(WEIRegistrationForm.Meta):
|
||||
fields = WEIRegistrationForm.Meta.fields + ['caution_type']
|
||||
widgets = WEIRegistrationForm.Meta.widgets.copy()
|
||||
widgets.update({
|
||||
"caution_type": forms.RadioSelect(),
|
||||
})
|
||||
|
||||
|
||||
class WEIRegistration1AForm(WEIRegistrationForm):
|
||||
class Meta(WEIRegistrationForm.Meta):
|
||||
fields = WEIRegistrationForm.Meta.fields
|
||||
|
||||
|
||||
class WEIChooseBusForm(forms.Form):
|
||||
bus = forms.ModelMultipleChoiceField(
|
||||
queryset=Bus.objects,
|
||||
@ -72,7 +96,7 @@ class WEIChooseBusForm(forms.Form):
|
||||
)
|
||||
|
||||
roles = forms.ModelMultipleChoiceField(
|
||||
queryset=WEIRole.objects.filter(~Q(name="1A")),
|
||||
queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")),
|
||||
label=_("WEI Roles"),
|
||||
help_text=_("Select the roles that you are interested in."),
|
||||
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
|
||||
@ -81,13 +105,8 @@ class WEIChooseBusForm(forms.Form):
|
||||
|
||||
|
||||
class WEIMembershipForm(forms.ModelForm):
|
||||
caution_check = forms.BooleanField(
|
||||
required=False,
|
||||
label=_("Caution check given"),
|
||||
)
|
||||
|
||||
roles = forms.ModelMultipleChoiceField(
|
||||
queryset=WEIRole.objects,
|
||||
queryset=WEIRole.objects.filter(~Q(name="GC WEI")),
|
||||
label=_("WEI Roles"),
|
||||
widget=CheckboxSelectMultiple(),
|
||||
)
|
||||
@ -121,6 +140,19 @@ class WEIMembershipForm(forms.ModelForm):
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, wei=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'bus' in self.fields:
|
||||
if wei is not None:
|
||||
self.fields['bus'].queryset = Bus.objects.filter(wei=wei)
|
||||
else:
|
||||
self.fields['bus'].queryset = Bus.objects.none()
|
||||
if 'team' in self.fields:
|
||||
if wei is not None:
|
||||
self.fields['team'].queryset = BusTeam.objects.filter(bus__wei=wei)
|
||||
else:
|
||||
self.fields['team'].queryset = BusTeam.objects.none()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if 'team' in cleaned_data and cleaned_data["team"] is not None \
|
||||
@ -132,21 +164,8 @@ class WEIMembershipForm(forms.ModelForm):
|
||||
model = WEIMembership
|
||||
fields = ('roles', 'bus', 'team',)
|
||||
widgets = {
|
||||
"bus": Autocomplete(
|
||||
Bus,
|
||||
attrs={
|
||||
'api_url': '/api/wei/bus/',
|
||||
'placeholder': 'Bus ...',
|
||||
}
|
||||
),
|
||||
"team": Autocomplete(
|
||||
BusTeam,
|
||||
attrs={
|
||||
'api_url': '/api/wei/team/',
|
||||
'placeholder': 'Équipe ...',
|
||||
},
|
||||
resetable=True,
|
||||
),
|
||||
"bus": RadioSelect(),
|
||||
"team": RadioSelect(),
|
||||
}
|
||||
|
||||
|
||||
|
@ -2,11 +2,11 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
|
||||
from .wei2024 import WEISurvey2024
|
||||
from .wei2025 import WEISurvey2025
|
||||
|
||||
|
||||
__all__ = [
|
||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||
]
|
||||
|
||||
CurrentSurvey = WEISurvey2024
|
||||
CurrentSurvey = WEISurvey2025
|
||||
|
@ -121,6 +121,13 @@ class WEISurveyAlgorithm:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def get_bus_information_form(cls):
|
||||
"""
|
||||
The class of the form to update the bus information.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class WEISurvey:
|
||||
"""
|
||||
|
347
apps/wei/forms/surveys/wei2025.py
Normal file
347
apps/wei/forms/surveys/wei2025.py
Normal file
@ -0,0 +1,347 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import time
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from random import Random
|
||||
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
|
||||
from ...models import WEIMembership, Bus
|
||||
|
||||
WORDS = [
|
||||
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
|
||||
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
|
||||
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
|
||||
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
|
||||
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
|
||||
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
|
||||
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
|
||||
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
|
||||
]
|
||||
|
||||
|
||||
class WEISurveyForm2025(forms.Form):
|
||||
"""
|
||||
Survey form for the year 2025.
|
||||
Members choose 20 words, from which we calculate the best associated bus.
|
||||
"""
|
||||
|
||||
word = forms.ChoiceField(
|
||||
label=_("Choose a word:"),
|
||||
widget=forms.RadioSelect(),
|
||||
)
|
||||
|
||||
def set_registration(self, registration):
|
||||
"""
|
||||
Filter the bus selector with the buses of the current WEI.
|
||||
"""
|
||||
information = WEISurveyInformation2025(registration)
|
||||
if not information.seed:
|
||||
information.seed = int(1000 * time.time())
|
||||
information.save(registration)
|
||||
registration._force_save = True
|
||||
registration.save()
|
||||
|
||||
if self.data:
|
||||
self.fields["word"].choices = [(w, w) for w in WORDS]
|
||||
if self.is_valid():
|
||||
return
|
||||
|
||||
rng = Random((information.step + 1) * information.seed)
|
||||
|
||||
buses = WEISurveyAlgorithm2025.get_buses()
|
||||
informations = {bus: WEIBusInformation2025(bus) for bus in buses}
|
||||
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
|
||||
if scores:
|
||||
average_score = sum(scores) / len(scores)
|
||||
else:
|
||||
average_score = 0
|
||||
|
||||
preferred_words = {bus: [word for word in WORDS
|
||||
if informations[bus].scores[word] >= average_score]
|
||||
for bus in buses}
|
||||
|
||||
# Correction : proposer plusieurs mots différents à chaque étape
|
||||
n_choices = 4 # Nombre de mots à proposer à chaque étape
|
||||
all_preferred_words = set()
|
||||
for bus_words in preferred_words.values():
|
||||
all_preferred_words.update(bus_words)
|
||||
all_preferred_words = list(all_preferred_words)
|
||||
rng.shuffle(all_preferred_words)
|
||||
words = all_preferred_words[:n_choices]
|
||||
self.fields["word"].choices = [(w, w) for w in words]
|
||||
|
||||
|
||||
class WEIBusInformation2025(WEIBusInformation):
|
||||
"""
|
||||
For each word, the bus has a score
|
||||
"""
|
||||
scores: dict
|
||||
|
||||
def __init__(self, bus):
|
||||
self.scores = {}
|
||||
for word in WORDS:
|
||||
self.scores[word] = 0
|
||||
super().__init__(bus)
|
||||
|
||||
|
||||
class BusInformationForm2025(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Bus
|
||||
fields = ['information_json']
|
||||
widgets = {}
|
||||
|
||||
def __init__(self, *args, words=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
initial_scores = {}
|
||||
if self.instance and self.instance.information_json:
|
||||
try:
|
||||
info = json.loads(self.instance.information_json)
|
||||
initial_scores = info.get("scores", {})
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
initial_scores = {}
|
||||
if words is None:
|
||||
words = WORDS
|
||||
self.words = words
|
||||
|
||||
choices = [(i, str(i)) for i in range(6)] # [(0, '0'), (1, '1'), ..., (5, '5')]
|
||||
for word in words:
|
||||
self.fields[word] = forms.TypedChoiceField(
|
||||
label=word,
|
||||
choices=choices,
|
||||
coerce=int,
|
||||
initial=initial_scores.get(word, 0),
|
||||
required=True,
|
||||
widget=forms.RadioSelect,
|
||||
help_text=_("Rate between 0 and 5."),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
scores = {}
|
||||
for word in self.words:
|
||||
value = cleaned_data.get(word)
|
||||
if value is not None:
|
||||
scores[word] = value
|
||||
# On encode en JSON
|
||||
cleaned_data['information_json'] = json.dumps({"scores": scores})
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class WEISurveyInformation2025(WEISurveyInformation):
|
||||
"""
|
||||
We store the id of the selected bus. We store only the name, but is not used in the selection:
|
||||
that's only for humans that try to read data.
|
||||
"""
|
||||
# Random seed that is stored at the first time to ensure that words are generated only once
|
||||
seed = 0
|
||||
step = 0
|
||||
|
||||
def __init__(self, registration):
|
||||
for i in range(1, 21):
|
||||
setattr(self, "word" + str(i), None)
|
||||
super().__init__(registration)
|
||||
|
||||
|
||||
class WEISurvey2025(WEISurvey):
|
||||
"""
|
||||
Survey for the year 2025.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_year(cls):
|
||||
return 2025
|
||||
|
||||
@classmethod
|
||||
def get_survey_information_class(cls):
|
||||
return WEISurveyInformation2025
|
||||
|
||||
def get_form_class(self):
|
||||
return WEISurveyForm2025
|
||||
|
||||
def update_form(self, form):
|
||||
"""
|
||||
Filter the bus selector with the buses of the WEI.
|
||||
"""
|
||||
form.set_registration(self.registration)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
word = form.cleaned_data["word"]
|
||||
self.information.step += 1
|
||||
setattr(self.information, "word" + str(self.information.step), word)
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def get_algorithm_class(cls):
|
||||
return WEISurveyAlgorithm2025
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""
|
||||
The survey is complete once the bus is chosen.
|
||||
"""
|
||||
return self.information.step == 20
|
||||
|
||||
@classmethod
|
||||
@lru_cache()
|
||||
def word_mean(cls, word):
|
||||
"""
|
||||
Calculate the mid-score given by all buses.
|
||||
"""
|
||||
buses = cls.get_algorithm_class().get_buses()
|
||||
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
|
||||
|
||||
@lru_cache()
|
||||
def score(self, bus):
|
||||
if not self.is_complete():
|
||||
raise ValueError("Survey is not ended, can't calculate score")
|
||||
|
||||
bus_info = self.get_algorithm_class().get_bus_information(bus)
|
||||
# Score is the given score by the bus subtracted to the mid-score of the buses.
|
||||
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
|
||||
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
|
||||
return s
|
||||
|
||||
@lru_cache()
|
||||
def scores_per_bus(self):
|
||||
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
|
||||
|
||||
@lru_cache()
|
||||
def ordered_buses(self):
|
||||
values = list(self.scores_per_bus().items())
|
||||
values.sort(key=lambda item: -item[1])
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls):
|
||||
cls.word_mean.cache_clear()
|
||||
return super().clear_cache()
|
||||
|
||||
|
||||
class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
|
||||
"""
|
||||
The algorithm class for the year 2025.
|
||||
We use Gale-Shapley algorithm to attribute 1y students into buses.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_survey_class(cls):
|
||||
return WEISurvey2025
|
||||
|
||||
@classmethod
|
||||
def get_bus_information_class(cls):
|
||||
return WEIBusInformation2025
|
||||
|
||||
@classmethod
|
||||
def get_bus_information_form(cls):
|
||||
return BusInformationForm2025
|
||||
|
||||
def run_algorithm(self, display_tqdm=False):
|
||||
"""
|
||||
Gale-Shapley algorithm implementation.
|
||||
We modify it to allow buses to have multiple "weddings".
|
||||
"""
|
||||
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
|
||||
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
|
||||
# Don't manage hardcoded people
|
||||
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
|
||||
|
||||
# Reset previous algorithm run
|
||||
for survey in surveys:
|
||||
survey.free()
|
||||
survey.save()
|
||||
|
||||
non_men = [s for s in surveys if s.registration.gender != 'male']
|
||||
men = [s for s in surveys if s.registration.gender == 'male']
|
||||
|
||||
quotas = {}
|
||||
registrations = self.get_registrations()
|
||||
non_men_total = registrations.filter(~Q(gender='male')).count()
|
||||
for bus in self.get_buses():
|
||||
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||
# Remove hardcoded people
|
||||
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||
registration__information_json__icontains="hardcoded").count()
|
||||
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
|
||||
|
||||
tqdm_obj = None
|
||||
if display_tqdm:
|
||||
from tqdm import tqdm
|
||||
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
|
||||
|
||||
# Repartition for non men people first
|
||||
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
|
||||
|
||||
quotas = {}
|
||||
for bus in self.get_buses():
|
||||
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
|
||||
# Remove hardcoded people
|
||||
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||
registration__information_json__icontains="hardcoded").count()
|
||||
quotas[bus] = free_seats
|
||||
|
||||
if display_tqdm:
|
||||
tqdm_obj.close()
|
||||
|
||||
from tqdm import tqdm
|
||||
tqdm_obj = tqdm(total=len(men), desc="Hommes")
|
||||
|
||||
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
|
||||
|
||||
if display_tqdm:
|
||||
tqdm_obj.close()
|
||||
|
||||
# Clear cache information after running algorithm
|
||||
WEISurvey2025.clear_cache()
|
||||
|
||||
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
|
||||
free_surveys = surveys.copy() # Remaining surveys
|
||||
while free_surveys: # Some students are not affected
|
||||
survey = free_surveys[0]
|
||||
buses = survey.ordered_buses() # Preferences of the student
|
||||
for bus, current_score in buses:
|
||||
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
|
||||
# Selected bus has free places. Put student in the bus
|
||||
survey.select_bus(bus)
|
||||
survey.save()
|
||||
free_surveys.remove(survey)
|
||||
break
|
||||
else:
|
||||
# Current bus has not enough places. Remove the least preferred student from the bus if existing
|
||||
least_preferred_survey = None
|
||||
least_score = -1
|
||||
# Find the least student in the bus that has a lower score than the current student
|
||||
for survey2 in surveys:
|
||||
if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
|
||||
continue
|
||||
score2 = survey2.score(bus)
|
||||
if current_score <= score2: # Ignore better students
|
||||
continue
|
||||
if least_preferred_survey is None or score2 < least_score:
|
||||
least_preferred_survey = survey2
|
||||
least_score = score2
|
||||
|
||||
if least_preferred_survey is not None:
|
||||
# Remove the least student from the bus and put the current student in.
|
||||
# If it does not exist, choose the next bus.
|
||||
least_preferred_survey.free()
|
||||
least_preferred_survey.save()
|
||||
free_surveys.append(least_preferred_survey)
|
||||
survey.select_bus(bus)
|
||||
survey.save()
|
||||
free_surveys.remove(survey)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"User {survey.registration.user} has no free seat")
|
||||
|
||||
if tqdm_obj is not None:
|
||||
tqdm_obj.n = len(surveys) - len(free_surveys)
|
||||
tqdm_obj.refresh()
|
18
apps/wei/migrations/0011_alter_weiclub_year.py
Normal file
18
apps/wei/migrations/0011_alter_weiclub_year.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.21 on 2025-05-25 12:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wei', '0010_remove_weiregistration_specific_diet'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='weiclub',
|
||||
name='year',
|
||||
field=models.PositiveIntegerField(default=2025, unique=True, verbose_name='year'),
|
||||
),
|
||||
]
|
20
apps/wei/migrations/0012_bus_club.py
Normal file
20
apps/wei/migrations/0012_bus_club.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.21 on 2025-05-29 16:16
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0014_create_bda'),
|
||||
('wei', '0011_alter_weiclub_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bus',
|
||||
name='club',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bus', to='member.club', verbose_name='club'),
|
||||
),
|
||||
]
|
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.21 on 2025-06-01 21:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wei', '0012_bus_club'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='weiclub',
|
||||
name='caution_amount',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='caution amount'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='weiregistration',
|
||||
name='caution_type',
|
||||
field=models.CharField(choices=[('check', 'Check'), ('note', 'Note transaction')], default='check', max_length=16, verbose_name='caution type'),
|
||||
),
|
||||
]
|
@ -33,6 +33,11 @@ class WEIClub(Club):
|
||||
verbose_name=_("date end"),
|
||||
)
|
||||
|
||||
caution_amount = models.PositiveIntegerField(
|
||||
verbose_name=_("caution amount"),
|
||||
default=0,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("WEI")
|
||||
verbose_name_plural = _("WEI")
|
||||
@ -72,6 +77,15 @@ class Bus(models.Model):
|
||||
default=50,
|
||||
)
|
||||
|
||||
club = models.OneToOneField(
|
||||
Club,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="bus",
|
||||
verbose_name=_("club"),
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
@ -188,6 +202,16 @@ class WEIRegistration(models.Model):
|
||||
verbose_name=_("Caution check given")
|
||||
)
|
||||
|
||||
caution_type = models.CharField(
|
||||
max_length=16,
|
||||
choices=(
|
||||
('check', _("Check")),
|
||||
('note', _("Note transaction")),
|
||||
),
|
||||
default='check',
|
||||
verbose_name=_("caution type"),
|
||||
)
|
||||
|
||||
birth_date = models.DateField(
|
||||
verbose_name=_("birth date"),
|
||||
)
|
||||
|
@ -98,7 +98,7 @@ class WEIRegistrationTable(tables.Table):
|
||||
if not hasperm:
|
||||
return format_html("<span class='no-perm'></span>")
|
||||
|
||||
url = reverse_lazy('wei:validate_registration', args=(record.pk,))
|
||||
url = reverse_lazy('wei:wei_update_registration', args=(record.pk,)) + '?validate=true'
|
||||
text = _('Validate')
|
||||
if record.fee > record.user.note.balance and not record.soge_credit:
|
||||
btn_class = 'btn-secondary'
|
||||
|
@ -40,22 +40,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<dt class="col-xl-6">{% trans 'membership fee'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd>
|
||||
{% else %}
|
||||
{% with bde_kfet_fee=club.parent_club.membership_fee_paid|add:club.parent_club.parent_club.membership_fee_paid %}
|
||||
<dt class="col-xl-6">{% trans 'WEI fee (paid students)'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_paid|add:bde_kfet_fee|pretty_money }}
|
||||
<i class="fa fa-question-circle"
|
||||
title="{% trans "The BDE membership is included in the WEI registration." %}"></i></dd>
|
||||
{% endwith %}
|
||||
|
||||
{% with bde_kfet_fee=club.parent_club.membership_fee_unpaid|add:club.parent_club.parent_club.membership_fee_unpaid %}
|
||||
<dt class="col-xl-6">{% trans 'WEI fee (paid students)'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}
|
||||
|
||||
<dt class="col-xl-6">{% trans 'WEI fee (unpaid students)'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_unpaid|add:bde_kfet_fee|pretty_money }}
|
||||
<i class="fa fa-question-circle"
|
||||
title="{% trans "The BDE membership is included in the WEI registration." %}"></i></dd>
|
||||
{% endwith %}
|
||||
<dd class="col-xl-6">{{ club.membership_fee_unpaid|pretty_money }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if club.caution_amount > 0 %}
|
||||
<dt class="col-xl-6">{% trans 'Caution amount'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.caution_amount|pretty_money }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if "note.view_note"|has_perm:club.note %}
|
||||
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.note.balance | pretty_money }}</dd>
|
||||
|
@ -16,8 +16,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-center">
|
||||
{% if object.club %}
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'member:club_detail' pk=object.club.pk %}"
|
||||
data-turbolinks="false">{% trans "View club" %}</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=object.pk %}"
|
||||
data-turbolinks="false">{% trans "Edit" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus_info' pk=object.pk %}"
|
||||
data-turbolinks="false">{% trans "Edit information" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=object.pk %}"
|
||||
data-turbolinks="false">{% trans "Add team" %}</a>
|
||||
</div>
|
||||
|
@ -18,6 +18,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<div class="card-footer text-center">
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=bus.pk %}"
|
||||
data-turbolinks="false">{% trans "Edit" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:manage_bus' pk=bus.pk %}"
|
||||
data-turbolinks="false">{% trans "View" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=bus.pk %}"
|
||||
data-turbolinks="false">{% trans "Add team" %}</a>
|
||||
</div>
|
||||
|
@ -13,9 +13,17 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.jscolor && jscolor.install) {
|
||||
jscolor.install();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -95,9 +95,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_validate_1a %}
|
||||
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
||||
{% endif %}
|
||||
{% if can_validate_1a %}
|
||||
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
|
@ -143,25 +143,35 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if registration.user.note.balance < fee %}
|
||||
<div class="alert alert-danger">
|
||||
{% with pretty_fee=fee|pretty_money %}
|
||||
{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
|
||||
The note don't have enough money ({{ balance }}, {{ pretty_fee }} required).
|
||||
The registration may fail if you don't credit the note now.
|
||||
{% endblocktrans %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed with pretty_fee=fee|pretty_money %}
|
||||
The note has enough money ({{ pretty_fee }} required), the registration is possible.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="alert {% if registration.user.note.balance < fee %}alert-danger{% else %}alert-success{% endif %}">
|
||||
<h5>{% trans "Required payments:" %}</h5>
|
||||
<ul>
|
||||
<li>{% blocktrans trimmed with amount=fee|pretty_money %}
|
||||
Membership fees: {{ amount }}
|
||||
{% endblocktrans %}</li>
|
||||
{% if registration.caution_type == 'note' %}
|
||||
<li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
|
||||
Deposit (by Note transaction): {{ amount }}
|
||||
{% endblocktrans %}</li>
|
||||
<li><strong>{% blocktrans trimmed with total=total_needed|pretty_money %}
|
||||
Total needed: {{ total }}
|
||||
{% endblocktrans %}</strong></li>
|
||||
{% else %}
|
||||
<li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
|
||||
Deposit (by check): {{ amount }}
|
||||
{% endblocktrans %}</li>
|
||||
<li><strong>{% blocktrans trimmed with total=fee|pretty_money %}
|
||||
Total needed: {{ total }}
|
||||
{% endblocktrans %}</strong></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<p>{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
|
||||
Current balance: {{ balance }}
|
||||
{% endblocktrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not registration.caution_check and not registration.first_year %}
|
||||
{% if not registration.caution_check and not registration.first_year and registration.caution_type == 'check' %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The user didn't give her/his caution check." %}
|
||||
</div>
|
||||
@ -200,4 +210,27 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
function refreshTeams() {
|
||||
let buses = [];
|
||||
$("input[name='bus']:checked").each(function (ignored) {
|
||||
buses.push($(this).parent().text().trim());
|
||||
});
|
||||
console.log(buses);
|
||||
$("input[name='team']").each(function () {
|
||||
let label = $(this).parent();
|
||||
$(this).parent().addClass('d-none');
|
||||
buses.forEach(function (bus) {
|
||||
if (label.text().includes(bus))
|
||||
label.removeClass('d-none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$("input[name='bus']").change(refreshTeams);
|
||||
|
||||
refreshTeams();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -6,8 +6,6 @@ from datetime import date, timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from note.models import NoteUser
|
||||
|
||||
from ..forms.surveys.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024
|
||||
from ..models import Bus, WEIClub, WEIRegistration
|
||||
@ -129,44 +127,3 @@ class TestWEIAlgorithm(TestCase):
|
||||
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
|
||||
|
||||
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
||||
|
||||
def test_register_1a(self):
|
||||
"""
|
||||
Test register a first year member to the WEI and complete the survey
|
||||
"""
|
||||
response = self.client.get(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user = User.objects.create(username="toto", email="toto@example.com")
|
||||
NoteUser.objects.create(user=user)
|
||||
response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict(
|
||||
user=user.id,
|
||||
soge_credit=True,
|
||||
birth_date=date(2000, 1, 1),
|
||||
gender='nonbinary',
|
||||
clothing_cut='female',
|
||||
clothing_size='XS',
|
||||
health_issues='I am a bot',
|
||||
emergency_contact_name='NoteKfet2020',
|
||||
emergency_contact_phone='+33123456789',
|
||||
))
|
||||
qs = WEIRegistration.objects.filter(user_id=user.id)
|
||||
self.assertTrue(qs.exists())
|
||||
registration = qs.get()
|
||||
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302, 200)
|
||||
for question in WORDS:
|
||||
# Fill 1A Survey, 10 pages
|
||||
# be careful if questionnary form change (number of page, type of answer...)
|
||||
response = self.client.post(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), {
|
||||
question: "1"
|
||||
})
|
||||
registration.refresh_from_db()
|
||||
survey = WEISurvey2024(registration)
|
||||
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302,
|
||||
302 if survey.is_complete() else 200)
|
||||
self.assertIsNotNone(getattr(survey.information, question), "Survey page " + question + " failed")
|
||||
survey = WEISurvey2024(registration)
|
||||
self.assertTrue(survey.is_complete())
|
||||
survey.select_bus(self.buses[0])
|
||||
survey.save()
|
||||
self.assertIsNotNone(survey.information.get_selected_bus())
|
||||
|
111
apps/wei/tests/test_wei_algorithm_2025.py
Normal file
111
apps/wei/tests/test_wei_algorithm_2025.py
Normal file
@ -0,0 +1,111 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
|
||||
from ..forms.surveys.wei2025 import WEIBusInformation2025, WEISurvey2025, WORDS, WEISurveyInformation2025
|
||||
from ..models import Bus, WEIClub, WEIRegistration
|
||||
|
||||
|
||||
class TestWEIAlgorithm(TestCase):
|
||||
"""
|
||||
Run some tests to ensure that the WEI algorithm is working well.
|
||||
"""
|
||||
fixtures = ('initial',)
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create some test data, with one WEI and 10 buses with random score attributions.
|
||||
"""
|
||||
self.wei = WEIClub.objects.create(
|
||||
name="WEI 2025",
|
||||
email="wei2025@example.com",
|
||||
date_start='2025-09-12',
|
||||
date_end='2025-09-14',
|
||||
year=2025,
|
||||
membership_start='2025-06-01'
|
||||
)
|
||||
|
||||
self.buses = []
|
||||
for i in range(10):
|
||||
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
|
||||
self.buses.append(bus)
|
||||
information = WEIBusInformation2025(bus)
|
||||
for word in WORDS:
|
||||
information.scores[word] = random.randint(0, 101)
|
||||
information.save()
|
||||
bus.save()
|
||||
|
||||
def test_survey_algorithm_small(self):
|
||||
"""
|
||||
There are only a few people in each bus, ensure that each person has its best bus
|
||||
"""
|
||||
# Add a few users
|
||||
for i in range(10):
|
||||
user = User.objects.create(username=f"user{i}")
|
||||
registration = WEIRegistration.objects.create(
|
||||
user=user,
|
||||
wei=self.wei,
|
||||
first_year=True,
|
||||
birth_date='2000-01-01',
|
||||
)
|
||||
information = WEISurveyInformation2025(registration)
|
||||
for j in range(1, 21):
|
||||
setattr(information, f'word{j}', random.choice(WORDS))
|
||||
information.step = 20
|
||||
information.save(registration)
|
||||
registration.save()
|
||||
|
||||
# Run algorithm
|
||||
WEISurvey2025.get_algorithm_class()().run_algorithm()
|
||||
|
||||
# Ensure that everyone has its first choice
|
||||
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||
survey = WEISurvey2025(r)
|
||||
preferred_bus = survey.ordered_buses()[0][0]
|
||||
chosen_bus = survey.information.get_selected_bus()
|
||||
self.assertEqual(preferred_bus, chosen_bus)
|
||||
|
||||
def test_survey_algorithm_full(self):
|
||||
"""
|
||||
Buses are full of first year people, ensure that they are happy
|
||||
"""
|
||||
# Add a lot of users
|
||||
for i in range(95):
|
||||
user = User.objects.create(username=f"user{i}")
|
||||
registration = WEIRegistration.objects.create(
|
||||
user=user,
|
||||
wei=self.wei,
|
||||
first_year=True,
|
||||
birth_date='2000-01-01',
|
||||
)
|
||||
information = WEISurveyInformation2025(registration)
|
||||
for j in range(1, 21):
|
||||
setattr(information, f'word{j}', random.choice(WORDS))
|
||||
information.step = 20
|
||||
information.save(registration)
|
||||
registration.save()
|
||||
|
||||
# Run algorithm
|
||||
WEISurvey2025.get_algorithm_class()().run_algorithm()
|
||||
|
||||
penalty = 0
|
||||
# Ensure that everyone seems to be happy
|
||||
# We attribute a penalty for each user that didn't have its first choice
|
||||
# The penalty is the square of the distance between the score of the preferred bus
|
||||
# and the score of the attributed bus
|
||||
# We consider it acceptable if the mean of this distance is lower than 5 %
|
||||
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||
survey = WEISurvey2025(r)
|
||||
chosen_bus = survey.information.get_selected_bus()
|
||||
buses = survey.ordered_buses()
|
||||
score = min(v for bus, v in buses if bus == chosen_bus)
|
||||
max_score = buses[0][1]
|
||||
penalty += (max_score - score) ** 2
|
||||
|
||||
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
|
||||
|
||||
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
@ -126,6 +126,7 @@ class TestWEIRegistration(TestCase):
|
||||
year=self.year + 1,
|
||||
date_start=str(self.year + 1) + "-09-01",
|
||||
date_end=str(self.year + 1) + "-09-03",
|
||||
caution_amount=12000,
|
||||
))
|
||||
qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1)
|
||||
self.assertTrue(qs.exists())
|
||||
@ -160,6 +161,7 @@ class TestWEIRegistration(TestCase):
|
||||
membership_end="2000-09-30",
|
||||
date_start="2000-09-01",
|
||||
date_end="2000-09-03",
|
||||
caution_amount=12000,
|
||||
))
|
||||
qs = WEIClub.objects.filter(name="Update WEI Test", id=self.wei.id)
|
||||
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.wei.pk)), 302, 200)
|
||||
@ -318,6 +320,7 @@ class TestWEIRegistration(TestCase):
|
||||
bus=[],
|
||||
team=[],
|
||||
roles=[],
|
||||
caution_type='check'
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.context["membership_form"].is_valid())
|
||||
@ -334,7 +337,8 @@ class TestWEIRegistration(TestCase):
|
||||
emergency_contact_phone='+33123456789',
|
||||
bus=[self.bus.id],
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()],
|
||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")).all()],
|
||||
caution_type='check'
|
||||
))
|
||||
qs = WEIRegistration.objects.filter(user_id=user.id)
|
||||
self.assertTrue(qs.exists())
|
||||
@ -354,6 +358,7 @@ class TestWEIRegistration(TestCase):
|
||||
bus=[self.bus.id],
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()],
|
||||
caution_type='check'
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue("This user is already registered to this WEI." in str(response.context["form"].errors))
|
||||
@ -506,11 +511,12 @@ class TestWEIRegistration(TestCase):
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||
information_json=self.registration.information_json,
|
||||
caution_type='check'
|
||||
)
|
||||
)
|
||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M")
|
||||
self.assertTrue(qs.exists())
|
||||
self.assertRedirects(response, reverse("wei:validate_registration", kwargs=dict(pk=qs.get().pk)), 302, 200)
|
||||
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=qs.get().wei.pk)), 302, 200)
|
||||
|
||||
# Check the page when the registration is already validated
|
||||
membership = WEIMembership(
|
||||
@ -560,11 +566,12 @@ class TestWEIRegistration(TestCase):
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||
information_json=self.registration.information_json,
|
||||
caution_type='check'
|
||||
)
|
||||
)
|
||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L")
|
||||
self.assertTrue(qs.exists())
|
||||
self.assertRedirects(response, reverse("wei:validate_registration", kwargs=dict(pk=qs.get().pk)), 302, 200)
|
||||
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=qs.get().wei.pk)), 302, 200)
|
||||
|
||||
# Test invalid form
|
||||
response = self.client.post(
|
||||
@ -583,6 +590,7 @@ class TestWEIRegistration(TestCase):
|
||||
team=[],
|
||||
roles=[],
|
||||
information_json=self.registration.information_json,
|
||||
caution_type='check'
|
||||
)
|
||||
)
|
||||
self.assertFalse(response.context["membership_form"].is_valid())
|
||||
@ -624,7 +632,7 @@ class TestWEIRegistration(TestCase):
|
||||
second_bus = Bus.objects.create(wei=self.wei, name="Second bus")
|
||||
second_team = BusTeam.objects.create(bus=second_bus, name="Second team", color=42)
|
||||
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
|
||||
roles=[WEIRole.objects.get(name="GC WEI").id],
|
||||
roles=[WEIRole.objects.get(name="Adhérent⋅e WEI").id],
|
||||
bus=self.bus.pk,
|
||||
team=second_team.pk,
|
||||
credit_type=4, # Bank transfer
|
||||
@ -632,13 +640,14 @@ class TestWEIRegistration(TestCase):
|
||||
last_name="admin",
|
||||
first_name="admin",
|
||||
bank="Société générale",
|
||||
caution_check=True,
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.context["form"].is_valid())
|
||||
self.assertTrue("This team doesn't belong to the given bus." in str(response.context["form"].errors))
|
||||
|
||||
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
|
||||
roles=[WEIRole.objects.get(name="GC WEI").id],
|
||||
roles=[WEIRole.objects.get(name="Adhérent⋅e WEI").id],
|
||||
bus=self.bus.pk,
|
||||
team=self.team.pk,
|
||||
credit_type=4, # Bank transfer
|
||||
@ -646,8 +655,10 @@ class TestWEIRegistration(TestCase):
|
||||
last_name="admin",
|
||||
first_name="admin",
|
||||
bank="Société générale",
|
||||
caution_check=True,
|
||||
))
|
||||
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
|
||||
|
||||
# Check if the membership is successfully created
|
||||
membership = WEIMembership.objects.filter(user_id=self.user.id, club=self.wei)
|
||||
self.assertTrue(membership.exists())
|
||||
@ -767,7 +778,7 @@ class TestDefaultWEISurvey(TestCase):
|
||||
WEISurvey.update_form(None, None)
|
||||
|
||||
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
|
||||
self.assertEqual(CurrentSurvey.get_year(), 2024)
|
||||
self.assertEqual(CurrentSurvey.get_year(), 2025)
|
||||
|
||||
|
||||
class TestWeiAPI(TestAPI):
|
||||
|
@ -4,7 +4,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
|
||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
|
||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, BusInformationUpdateView, \
|
||||
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
|
||||
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
|
||||
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
|
||||
@ -42,4 +42,5 @@ urlpatterns = [
|
||||
path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"),
|
||||
path('bus-1A/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
|
||||
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
|
||||
path('update-bus-info/<int:pk>/', BusInformationUpdateView.as_view(), name="update_bus_info"),
|
||||
]
|
||||
|
@ -4,16 +4,18 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import date, timedelta
|
||||
from datetime import date
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, Count
|
||||
from django.db.models.functions.text import Lower
|
||||
from django import forms
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import render_to_string
|
||||
@ -33,7 +35,7 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
|
||||
from .forms.registration import WEIChooseBusForm
|
||||
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
|
||||
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \
|
||||
from .forms import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, BusForm, BusTeamForm, WEIMembership1AForm, \
|
||||
WEIMembershipForm, CurrentSurvey
|
||||
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
|
||||
WEIRegistration1ATable, WEIMembershipTable
|
||||
@ -441,6 +443,10 @@ class BusTeamCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_template_names(self):
|
||||
names = super().get_template_names()
|
||||
return names
|
||||
|
||||
|
||||
class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
@ -473,6 +479,10 @@ class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_template_names(self):
|
||||
names = super().get_template_names()
|
||||
return names
|
||||
|
||||
|
||||
class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
@ -500,7 +510,7 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
Register a new user to the WEI
|
||||
"""
|
||||
model = WEIRegistration
|
||||
form_class = WEIRegistrationForm
|
||||
form_class = WEIRegistration1AForm
|
||||
extra_context = {"title": _("Register first year student to the WEI")}
|
||||
|
||||
def get_sample_object(self):
|
||||
@ -546,9 +556,17 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.fields["user"].initial = self.request.user
|
||||
del form.fields["first_year"]
|
||||
del form.fields["caution_check"]
|
||||
del form.fields["information_json"]
|
||||
|
||||
# Cacher les champs pendant l'inscription initiale
|
||||
if "first_year" in form.fields:
|
||||
del form.fields["first_year"]
|
||||
if "caution_check" in form.fields:
|
||||
del form.fields["caution_check"]
|
||||
if "information_json" in form.fields:
|
||||
del form.fields["information_json"]
|
||||
if "caution_type" in form.fields:
|
||||
del form.fields["caution_type"]
|
||||
|
||||
return form
|
||||
|
||||
@transaction.atomic
|
||||
@ -586,7 +604,7 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
Register an old user to the WEI
|
||||
"""
|
||||
model = WEIRegistration
|
||||
form_class = WEIRegistrationForm
|
||||
form_class = WEIRegistration2AForm
|
||||
extra_context = {"title": _("Register old student to the WEI")}
|
||||
|
||||
def get_sample_object(self):
|
||||
@ -644,9 +662,19 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
form.fields["soge_credit"].disabled = True
|
||||
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
||||
|
||||
del form.fields["caution_check"]
|
||||
del form.fields["first_year"]
|
||||
del form.fields["information_json"]
|
||||
# Cacher les champs pendant l'inscription initiale
|
||||
if "first_year" in form.fields:
|
||||
del form.fields["first_year"]
|
||||
if "caution_check" in form.fields:
|
||||
del form.fields["caution_check"]
|
||||
if "information_json" in form.fields:
|
||||
del form.fields["information_json"]
|
||||
|
||||
# S'assurer que le champ caution_type est obligatoire
|
||||
if "caution_type" in form.fields:
|
||||
form.fields["caution_type"].required = True
|
||||
form.fields["caution_type"].help_text = _("Choose how you want to pay the deposit")
|
||||
form.fields["caution_type"].widget = forms.RadioSelect(choices=form.fields["caution_type"].choices)
|
||||
|
||||
return form
|
||||
|
||||
@ -673,6 +701,9 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]]
|
||||
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
||||
form.instance.information = information
|
||||
|
||||
# Sauvegarder le type de caution
|
||||
form.instance.caution_type = form.cleaned_data["caution_type"]
|
||||
form.instance.save()
|
||||
|
||||
if 'treasury' in settings.INSTALLED_APPS:
|
||||
@ -702,11 +733,15 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
# We can't update a registration once the WEI is started and before the membership start date
|
||||
if today >= wei.date_start or today < wei.membership_start:
|
||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||
# Store the validate parameter in the view's state
|
||||
self.should_validate = request.GET.get('validate', False)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["club"] = self.object.wei
|
||||
# Pass the validate parameter to the template
|
||||
context["should_validate"] = self.should_validate
|
||||
|
||||
if self.object.is_validated:
|
||||
membership_form = self.get_membership_form(instance=self.object.membership,
|
||||
@ -740,10 +775,21 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
# The auto-json-format may cause issues with the default field remove
|
||||
if not PermissionBackend.check_perm(self.request, 'wei.change_weiregistration_information_json', self.object):
|
||||
del form.fields["information_json"]
|
||||
# Masquer le champ caution_check pour tout le monde dans le formulaire de modification
|
||||
if "caution_check" in form.fields:
|
||||
del form.fields["caution_check"]
|
||||
|
||||
# S'assurer que le champ caution_type est obligatoire pour les 2A+
|
||||
if not self.object.first_year and "caution_type" in form.fields:
|
||||
form.fields["caution_type"].required = True
|
||||
form.fields["caution_type"].help_text = _("Choose how you want to pay the deposit")
|
||||
form.fields["caution_type"].widget = forms.RadioSelect(choices=form.fields["caution_type"].choices)
|
||||
|
||||
return form
|
||||
|
||||
def get_membership_form(self, data=None, instance=None):
|
||||
membership_form = WEIMembershipForm(data if data else None, instance=instance)
|
||||
registration = self.get_object()
|
||||
membership_form = WEIMembershipForm(data if data else None, instance=instance, wei=registration.wei)
|
||||
del membership_form.fields["credit_type"]
|
||||
del membership_form.fields["credit_amount"]
|
||||
del membership_form.fields["first_name"]
|
||||
@ -759,10 +805,30 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
def form_valid(self, form):
|
||||
# If the membership is already validated, then we update the bus and the team (and the roles)
|
||||
if form.instance.is_validated:
|
||||
membership_form = self.get_membership_form(self.request.POST, form.instance.membership)
|
||||
if not membership_form.is_valid():
|
||||
try:
|
||||
membership = form.instance.membership
|
||||
if membership is None:
|
||||
raise ValueError(_("No membership found for this registration"))
|
||||
|
||||
membership_form = self.get_membership_form(self.request.POST, instance=membership)
|
||||
if not membership_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Vérifier que l'utilisateur a la permission de modifier le membership
|
||||
# On vérifie d'abord si l'utilisateur a la permission générale de modification
|
||||
if not self.request.user.has_perm("wei.change_weimembership"):
|
||||
raise PermissionDenied(_("You don't have the permission to update memberships"))
|
||||
|
||||
# On vérifie ensuite les permissions spécifiques pour chaque champ modifié
|
||||
for field_name in membership_form.changed_data:
|
||||
perm = f"wei.change_weimembership_{field_name}"
|
||||
if not self.request.user.has_perm(perm):
|
||||
raise PermissionDenied(_("You don't have the permission to update the field %(field)s") % {'field': field_name})
|
||||
|
||||
membership_form.save()
|
||||
except (WEIMembership.DoesNotExist, ValueError, PermissionDenied) as e:
|
||||
form.add_error(None, str(e))
|
||||
return self.form_invalid(form)
|
||||
membership_form.save()
|
||||
# If it is not validated and if this is an old member, then we update the choices
|
||||
elif not form.instance.first_year and PermissionBackend.check_perm(
|
||||
self.request, "wei.change_weiregistration_information_json", self.object):
|
||||
@ -777,6 +843,10 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]]
|
||||
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
||||
form.instance.information = information
|
||||
|
||||
# Sauvegarder le type de caution pour les 2A+
|
||||
if "caution_type" in form.cleaned_data:
|
||||
form.instance.caution_type = form.cleaned_data["caution_type"]
|
||||
form.instance.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
@ -787,14 +857,8 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
survey = CurrentSurvey(self.object)
|
||||
if not survey.is_complete():
|
||||
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
|
||||
if PermissionBackend.check_perm(self.request, "wei.add_weimembership", WEIMembership(
|
||||
club=self.object.wei,
|
||||
user=self.object.user,
|
||||
date_start=date.today(),
|
||||
date_end=date.today(),
|
||||
fee=0,
|
||||
registration=self.object,
|
||||
)):
|
||||
# On redirige vers la validation uniquement si c'est explicitement demandé (et stocké dans la vue)
|
||||
if self.should_validate and self.request.user.has_perm("wei.add_weimembership"):
|
||||
return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk})
|
||||
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.pk})
|
||||
|
||||
@ -836,18 +900,23 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
extra_context = {"title": _("Validate WEI registration")}
|
||||
|
||||
def get_sample_object(self):
|
||||
"""
|
||||
Return a sample object for permission checking
|
||||
"""
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
return WEIMembership(
|
||||
club=registration.wei,
|
||||
user=registration.user,
|
||||
date_start=date.today(),
|
||||
date_end=date.today() + timedelta(days=1),
|
||||
fee=0,
|
||||
club=registration.wei,
|
||||
date_start=registration.wei.date_start,
|
||||
fee=registration.wei.membership_fee_paid if registration.user.profile.paid else registration.wei.membership_fee_unpaid,
|
||||
# Add any fields needed for proper permission checking
|
||||
registration=registration,
|
||||
)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
wei = WEIRegistration.objects.get(pk=self.kwargs["pk"]).wei
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
|
||||
wei = registration.wei
|
||||
today = date.today()
|
||||
# We can't validate anyone once the WEI is started and before the membership start date
|
||||
if today >= wei.date_start or today < wei.membership_start:
|
||||
@ -878,7 +947,14 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
date_start__gte=bde.membership_start,
|
||||
).exists()
|
||||
|
||||
context["fee"] = registration.fee
|
||||
fee = registration.fee
|
||||
context["fee"] = fee
|
||||
|
||||
# Calculer le montant total nécessaire (frais + caution si transaction)
|
||||
total_needed = fee
|
||||
if registration.caution_type == 'note':
|
||||
total_needed += registration.wei.caution_amount
|
||||
context["total_needed"] = total_needed
|
||||
|
||||
form = context["form"]
|
||||
if registration.soge_credit:
|
||||
@ -890,18 +966,41 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
def get_form_class(self):
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
if registration.first_year and 'sleected_bus_pk' not in registration.information:
|
||||
if registration.first_year and 'selected_bus_pk' not in registration.information:
|
||||
return WEIMembership1AForm
|
||||
return WEIMembershipForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
wei = registration.wei
|
||||
kwargs['wei'] = wei
|
||||
return kwargs
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
form.fields["last_name"].initial = registration.user.last_name
|
||||
form.fields["first_name"].initial = registration.user.first_name
|
||||
|
||||
if "caution_check" in form.fields:
|
||||
form.fields["caution_check"].initial = registration.caution_check
|
||||
# Ajouter le champ caution_check uniquement pour les non-première année et le rendre obligatoire
|
||||
if not registration.first_year:
|
||||
if registration.caution_type == 'check':
|
||||
form.fields["caution_check"] = forms.BooleanField(
|
||||
required=True,
|
||||
initial=registration.caution_check,
|
||||
label=_("Caution check given"),
|
||||
help_text=_("Please make sure the check is given before validating the registration")
|
||||
)
|
||||
else:
|
||||
form.fields["caution_check"] = forms.BooleanField(
|
||||
required=True,
|
||||
initial=False,
|
||||
label=_("Create deposit transaction"),
|
||||
help_text=_("A transaction of %(amount).2f€ will be created from the user's Note account") % {
|
||||
'amount': registration.wei.caution_amount / 100
|
||||
}
|
||||
)
|
||||
|
||||
if registration.soge_credit:
|
||||
form.fields["credit_type"].disabled = True
|
||||
@ -985,10 +1084,20 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
if credit_type is None or registration.soge_credit:
|
||||
credit_amount = 0
|
||||
|
||||
if not registration.soge_credit and user.note.balance + credit_amount < fee:
|
||||
# Users must have money before registering to the WEI.
|
||||
# Calculer le montant total nécessaire (frais + caution si transaction)
|
||||
total_needed = fee
|
||||
if registration.caution_type == 'note':
|
||||
total_needed += club.caution_amount
|
||||
|
||||
# Vérifier que l'utilisateur a assez d'argent pour tout payer
|
||||
if not registration.soge_credit and user.note.balance + credit_amount < total_needed:
|
||||
form.add_error('credit_type',
|
||||
_("This user don't have enough money to join this club, and can't have a negative balance."))
|
||||
_("This user doesn't have enough money to join this club and pay the deposit. "
|
||||
"Current balance: %(balance)d€, credit: %(credit)d€, needed: %(needed)d€") % {
|
||||
'balance': user.note.balance,
|
||||
'credit': credit_amount,
|
||||
'needed': total_needed}
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
if credit_amount:
|
||||
@ -1028,6 +1137,18 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
membership.refresh_from_db()
|
||||
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
|
||||
|
||||
# Créer la transaction de caution si nécessaire
|
||||
if registration.caution_type == 'note':
|
||||
from note.models import Transaction
|
||||
Transaction.objects.create(
|
||||
source=user.note,
|
||||
destination=club.note,
|
||||
quantity=1,
|
||||
amount=club.caution_amount,
|
||||
reason=_("Caution %(name)s") % {'name': club.name},
|
||||
valid=True,
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
@ -1247,6 +1368,7 @@ class WEI1AListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableView):
|
||||
def get_queryset(self, filter_permissions=True, **kwargs):
|
||||
qs = super().get_queryset(filter_permissions, **kwargs)
|
||||
qs = qs.filter(first_year=True, membership__isnull=False)
|
||||
qs = qs.filter(wei=self.club)
|
||||
qs = qs.order_by('-membership__bus')
|
||||
return qs
|
||||
|
||||
@ -1289,8 +1411,48 @@ class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
|
||||
if not wei.exists():
|
||||
raise Http404
|
||||
wei = wei.get()
|
||||
qs = WEIRegistration.objects.filter(wei=wei, membership__isnull=False, membership__bus__isnull=True)
|
||||
qs = qs.filter(information_json__contains='selected_bus_pk') # not perfect, but works...
|
||||
if qs.exists():
|
||||
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk, ))
|
||||
return reverse_lazy('wei:wei_1A_list', args=(wei.pk, ))
|
||||
|
||||
# On cherche d'abord les 1A qui ont une inscription validée (membership) mais pas de bus
|
||||
qs = WEIRegistration.objects.filter(
|
||||
wei=wei,
|
||||
first_year=True,
|
||||
membership__isnull=False,
|
||||
membership__bus__isnull=True
|
||||
)
|
||||
|
||||
# Parmi eux, on prend ceux qui ont répondu au questionnaire (ont un bus préféré)
|
||||
qs = qs.filter(information_json__contains='selected_bus_pk')
|
||||
|
||||
if not qs.exists():
|
||||
# Si on ne trouve personne, on affiche un message et on retourne à la liste
|
||||
messages.info(self.request, _("No first year student without a bus found. Either all of them have a bus, or none has filled the survey yet."))
|
||||
return reverse_lazy('wei:wei_1A_list', args=(wei.pk,))
|
||||
|
||||
# On redirige vers la page d'attribution pour le premier étudiant trouvé
|
||||
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk,))
|
||||
|
||||
|
||||
class BusInformationUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
model = Bus
|
||||
|
||||
def get_form_class(self):
|
||||
return CurrentSurvey.get_algorithm_class().get_bus_information_form()
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
wei = self.get_object().wei
|
||||
today = date.today()
|
||||
# We can't update a bus once the WEI is started
|
||||
if today >= wei.date_start:
|
||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["club"] = self.object.wei
|
||||
context["information"] = CurrentSurvey.get_algorithm_class().get_bus_information(self.object)
|
||||
self.object.save()
|
||||
return context
|
||||
|
||||
def get_success_url(self):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy("wei:manage_bus", kwargs={"pk": self.object.pk})
|
||||
|
@ -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):
|
||||
|
@ -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 %}
|
||||
|
@ -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) {
|
||||
|
0
apps/wrapped/tests/__init__.py
Normal file
0
apps/wrapped/tests/__init__.py
Normal file
91
apps/wrapped/tests/test_wrapped.py
Normal file
91
apps/wrapped/tests/test_wrapped.py
Normal 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/")
|
@ -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
118
docs/_static/img/graphs/wrapped.svg
vendored
Normal 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">    </text>
|
||||
<text text-anchor="start" x="62" y="-65.5" font-family="Roboto" font-weight="bold" font-size="10.00" fill="white">    Bde    </text>
|
||||
<text text-anchor="start" x="11" y="-49.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="77" y="-49.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="11" y="-36.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="77" y="-36.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="11" y="-23.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="77" y="-23.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="11" y="-10.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="77" y="-10.1" font-family="Roboto" font-size="8.00">    </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">    </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">    </text>
|
||||
<text text-anchor="start" x="113" y="-219.5" font-family="Roboto" font-weight="bold" font-size="10.00" fill="white">    Wrapped    </text>
|
||||
<text text-anchor="start" x="70" y="-203.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="137" y="-203.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="70" y="-190.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="137" y="-190.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="70" y="-177.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="137" y="-177.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="70" y="-164.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="137" y="-164.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="70" y="-151.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="137" y="-151.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="70" y="-138.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<text text-anchor="start" x="137" y="-138.1" font-family="Roboto" font-size="8.00">    </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">    </text>
|
||||
<polygon fill="none" stroke="black" points="67,-132 67,-233 231,-233 231,-132 67,-132"/>
|
||||
</g>
|
||||
<!-- wrapped_models_Wrapped->wrapped_models_Bde -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>wrapped_models_Wrapped->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">  </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">  </text>
|
||||
</g>
|
||||
<!-- wrapped_models_Wrapped->note_models_notes_Note -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>wrapped_models_Wrapped->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 |
@ -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 :
|
||||
|
@ -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
|
||||
|
@ -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
108
docs/apps/wrapped.rst
Normal 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.
|
@ -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é.
|
||||
|
||||
|
||||
|
@ -183,6 +183,7 @@ Contributeur⋅rices
|
||||
* korenst1
|
||||
* nicomarg
|
||||
* PAC
|
||||
* Quark
|
||||
* ÿnérant
|
||||
|
||||
|
||||
|
@ -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
|
||||
------------------------------------
|
||||
|
||||
|
@ -136,7 +136,7 @@ de diffusion utiles.
|
||||
Faîtes attention, donc où la sortie est stockée.
|
||||
|
||||
|
||||
Il prend 2 options :
|
||||
Il prend 4 options :
|
||||
|
||||
* ``--type``, qui prend en argument ``members`` (défaut), ``clubs``, ``events``, ``art``,
|
||||
``sport``, qui permet respectivement de sortir la liste des adresses mails des adhérent⋅es
|
||||
@ -149,7 +149,10 @@ Il prend 2 options :
|
||||
pour la ML Adhérents, pour exporter les mails des adhérents au BDE pendant n'importe
|
||||
laquelle des ``n+1`` dernières années.
|
||||
|
||||
Le script sort sur la sortie standard la liste des adresses mails à inscrire.
|
||||
* ``--email``, qui prend en argument une chaine de caractère contenant une adresse email.
|
||||
|
||||
Si aucun email n'est renseigné, le script sort sur la sortie standard la liste des adresses mails à inscrire.
|
||||
Dans le cas contraire, la liste est envoyée à l'adresse passée en argument.
|
||||
|
||||
Attention : il y a parfois certains cas particuliers à prendre en compte, il n'est
|
||||
malheureusement pas aussi simple que de simplement supposer que ces listes sont exhaustives.
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user