mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-07-21 16:39:12 +02:00
Compare commits
106 Commits
ebc77b3329
...
django-5.2
Author | SHA1 | Date | |
---|---|---|---|
85ea43a7cf | |||
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 | |||
7966d6f397 | |||
cb61c511ce | |||
25bfa575ed | |||
e21d9fcfbe | |||
b293904525 | |||
bd7e6b8ad4 | |||
a208a4fa25 | |||
4799b2c52d | |||
562dcfb908 | |||
12ef258ff0 | |||
2ae32ee3b6 | |||
ec1bd45481 | |||
370a9a069e | |||
7f0a3784e9 | |||
6c63c6417c | |||
4563b2b640 |
@ -21,3 +21,6 @@ EMAIL_PASSWORD=CHANGE_ME
|
|||||||
# Wiki configuration
|
# Wiki configuration
|
||||||
WIKI_USER=NoteKfet2020
|
WIKI_USER=NoteKfet2020
|
||||||
WIKI_PASSWORD=
|
WIKI_PASSWORD=
|
||||||
|
|
||||||
|
# OIDC
|
||||||
|
OIDC_RSA_PRIVATE_KEY=CHANGE_ME
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -48,6 +48,7 @@ backups/
|
|||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
shell.nix
|
||||||
|
|
||||||
# ansibles customs host
|
# ansibles customs host
|
||||||
ansible/host_vars/*.yaml
|
ansible/host_vars/*.yaml
|
||||||
|
@ -8,7 +8,7 @@ variables:
|
|||||||
GIT_SUBMODULE_STRATEGY: recursive
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
|
||||||
# Ubuntu 22.04
|
# Ubuntu 22.04
|
||||||
py310-django42:
|
py310-django52:
|
||||||
stage: test
|
stage: test
|
||||||
image: ubuntu:22.04
|
image: ubuntu:22.04
|
||||||
before_script:
|
before_script:
|
||||||
@ -22,10 +22,10 @@ py310-django42:
|
|||||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||||
python3-bs4 python3-setuptools tox texlive-xetex
|
python3-bs4 python3-setuptools tox texlive-xetex
|
||||||
script: tox -e py310-django42
|
script: tox -e py310-django52
|
||||||
|
|
||||||
# Debian Bookworm
|
# Debian Bookworm
|
||||||
py311-django42:
|
py311-django52:
|
||||||
stage: test
|
stage: test
|
||||||
image: debian:bookworm
|
image: debian:bookworm
|
||||||
before_script:
|
before_script:
|
||||||
@ -37,7 +37,7 @@ py311-django42:
|
|||||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||||
python3-bs4 python3-setuptools tox texlive-xetex
|
python3-bs4 python3-setuptools tox texlive-xetex
|
||||||
script: tox -e py311-django42
|
script: tox -e py311-django52
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
stage: quality-assurance
|
stage: quality-assurance
|
||||||
|
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
|
(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
|
```bash
|
||||||
(env)$ ./manage.py runserver 0.0.0.0:8000
|
(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 check # pas de bêtise qui traine
|
||||||
(env)$ ./manage.py migrate
|
(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
|
### Installation avec Docker
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ class GuestAdmin(admin.ModelAdmin):
|
|||||||
"""
|
"""
|
||||||
Admin customisation for Guest
|
Admin customisation for Guest
|
||||||
"""
|
"""
|
||||||
list_display = ('last_name', 'first_name', 'activity', 'inviter')
|
list_display = ('last_name', 'first_name', 'school', 'activity', 'inviter')
|
||||||
form = GuestForm
|
form = GuestForm
|
||||||
|
|
||||||
|
|
||||||
|
@ -51,9 +51,9 @@ class GuestViewSet(ReadProtectedModelViewSet):
|
|||||||
queryset = Guest.objects.order_by('id')
|
queryset = Guest.objects.order_by('id')
|
||||||
serializer_class = GuestSerializer
|
serializer_class = GuestSerializer
|
||||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
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', ]
|
'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', ]
|
'$inviter__alias__normalized_name', ]
|
||||||
|
|
||||||
|
|
||||||
|
@ -107,7 +107,7 @@ class GuestForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Guest
|
model = Guest
|
||||||
fields = ('last_name', 'first_name', 'inviter', )
|
fields = ('last_name', 'first_name', 'school', 'inviter', )
|
||||||
widgets = {
|
widgets = {
|
||||||
"inviter": Autocomplete(
|
"inviter": Autocomplete(
|
||||||
NoteUser,
|
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):
|
def save(self, *args, **kwargs):
|
||||||
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
|
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
|
||||||
if qs.exists():
|
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:
|
if self.guest:
|
||||||
self.note = self.guest.inviter
|
self.note = self.guest.inviter
|
||||||
@ -233,7 +234,7 @@ class Guest(models.Model):
|
|||||||
"""
|
"""
|
||||||
activity = models.ForeignKey(
|
activity = models.ForeignKey(
|
||||||
Activity,
|
Activity,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.CASCADE,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -247,6 +248,11 @@ class Guest(models.Model):
|
|||||||
verbose_name=_("first name"),
|
verbose_name=_("first name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
school = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("school"),
|
||||||
|
)
|
||||||
|
|
||||||
inviter = models.ForeignKey(
|
inviter = models.ForeignKey(
|
||||||
NoteUser,
|
NoteUser,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -51,11 +51,11 @@ class GuestTable(tables.Table):
|
|||||||
}
|
}
|
||||||
model = Guest
|
model = Guest
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
fields = ("last_name", "first_name", "inviter", )
|
fields = ("last_name", "first_name", "inviter", "school")
|
||||||
|
|
||||||
def render_entry(self, record):
|
def render_entry(self, record):
|
||||||
if record.has_entry:
|
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)"> '
|
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()))
|
'{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);
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -70,7 +70,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% if ".change_"|has_perm:activity %}
|
{% 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>
|
<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 %}
|
{% 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>
|
<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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -50,6 +50,7 @@ class TestActivities(TestCase):
|
|||||||
inviter=self.user.note,
|
inviter=self.user.note,
|
||||||
last_name="GUEST",
|
last_name="GUEST",
|
||||||
first_name="Guest",
|
first_name="Guest",
|
||||||
|
school="School",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_activity_list(self):
|
def test_activity_list(self):
|
||||||
@ -156,6 +157,7 @@ class TestActivities(TestCase):
|
|||||||
inviter=self.user.note.id,
|
inviter=self.user.note.id,
|
||||||
last_name="GUEST2",
|
last_name="GUEST2",
|
||||||
first_name="Guest",
|
first_name="Guest",
|
||||||
|
school="School",
|
||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -167,6 +169,7 @@ class TestActivities(TestCase):
|
|||||||
inviter=self.user.note.id,
|
inviter=self.user.note.id,
|
||||||
last_name="GUEST2",
|
last_name="GUEST2",
|
||||||
first_name="Guest",
|
first_name="Guest",
|
||||||
|
school="School",
|
||||||
))
|
))
|
||||||
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
|
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
|
||||||
|
|
||||||
@ -200,6 +203,7 @@ class TestActivityAPI(TestAPI):
|
|||||||
inviter=self.user.note,
|
inviter=self.user.note,
|
||||||
last_name="GUEST",
|
last_name="GUEST",
|
||||||
first_name="Guest",
|
first_name="Guest",
|
||||||
|
school="School",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.entry = Entry.objects.create(
|
self.entry = Entry.objects.create(
|
||||||
|
@ -15,4 +15,5 @@ urlpatterns = [
|
|||||||
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
|
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
|
||||||
path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
|
path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
|
||||||
path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'),
|
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.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Q
|
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.urls import reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
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"]})
|
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):
|
class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
"""
|
"""
|
||||||
Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`
|
Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`
|
||||||
@ -168,6 +196,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
activity=activity,
|
activity=activity,
|
||||||
first_name="",
|
first_name="",
|
||||||
last_name="",
|
last_name="",
|
||||||
|
school="",
|
||||||
inviter=self.request.user.note,
|
inviter=self.request.user.note,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -2,36 +2,58 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.db import transaction
|
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
|
||||||
from note_kfet.admin import admin_site
|
from note_kfet.admin import admin_site
|
||||||
|
|
||||||
from .models import Allergen, BasicFood, QRCode, TransformedFood
|
from .models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
||||||
|
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Allergen, site=admin_site)
|
@admin.register(Allergen, site=admin_site)
|
||||||
class AllergenAdmin(admin.ModelAdmin):
|
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 rest_framework import serializers
|
||||||
|
|
||||||
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
||||||
|
|
||||||
|
|
||||||
class AllergenSerializer(serializers.ModelSerializer):
|
class AllergenSerializer(serializers.ModelSerializer):
|
||||||
@ -11,40 +11,46 @@ class AllergenSerializer(serializers.ModelSerializer):
|
|||||||
REST API Serializer for Allergen.
|
REST API Serializer for Allergen.
|
||||||
The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
|
The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Allergen
|
model = Allergen
|
||||||
fields = '__all__'
|
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):
|
class BasicFoodSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
REST API Serializer for BasicFood.
|
REST API Serializer for BasicFood.
|
||||||
The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
|
The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = BasicFood
|
model = BasicFood
|
||||||
fields = '__all__'
|
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):
|
class TransformedFoodSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
REST API Serializer for TransformedFood.
|
REST API Serializer for TransformedFood.
|
||||||
The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
|
The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransformedFood
|
model = TransformedFood
|
||||||
fields = '__all__'
|
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
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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):
|
def register_food_urls(router, path):
|
||||||
@ -9,6 +9,7 @@ def register_food_urls(router, path):
|
|||||||
Configure router for Food REST API.
|
Configure router for Food REST API.
|
||||||
"""
|
"""
|
||||||
router.register(path + '/allergen', AllergenViewSet)
|
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 + '/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 django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
|
|
||||||
from .serializers import AllergenSerializer, BasicFoodSerializer, QRCodeSerializer, TransformedFoodSerializer
|
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
|
||||||
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
||||||
|
|
||||||
|
|
||||||
class AllergenViewSet(ReadProtectedModelViewSet):
|
class AllergenViewSet(ReadProtectedModelViewSet):
|
||||||
@ -22,11 +22,24 @@ class AllergenViewSet(ReadProtectedModelViewSet):
|
|||||||
search_fields = ['$name', ]
|
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):
|
class BasicFoodViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer,
|
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')
|
queryset = BasicFood.objects.order_by('id')
|
||||||
serializer_class = BasicFoodSerializer
|
serializer_class = BasicFoodSerializer
|
||||||
@ -35,6 +48,19 @@ class BasicFoodViewSet(ReadProtectedModelViewSet):
|
|||||||
search_fields = ['$name', ]
|
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):
|
class QRCodeViewSet(ReadProtectedModelViewSet):
|
||||||
"""
|
"""
|
||||||
REST API View set.
|
REST API View set.
|
||||||
@ -46,16 +72,3 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
|
|||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||||
filterset_fields = ['qr_code_number', ]
|
filterset_fields = ['qr_code_number', ]
|
||||||
search_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 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 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.inputs import Autocomplete
|
||||||
from note_kfet.middlewares import get_current_request
|
from note_kfet.middlewares import get_current_request
|
||||||
from permission.backends import PermissionBackend
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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',
|
polymorphic_ctype__model='transformedfood',
|
||||||
is_ready=False,
|
).filter(PermissionBackend.filter_queryset(
|
||||||
is_active=True,
|
get_current_request(),
|
||||||
was_eaten=False,
|
TransformedFood,
|
||||||
)
|
"view",
|
||||||
# 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")
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransformedFood
|
model = QRCode
|
||||||
fields = ('ingredient', 'is_active')
|
fields = ('food_container',)
|
||||||
|
|
||||||
|
|
||||||
class BasicFoodForms(forms.ModelForm):
|
class BasicFoodForms(forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Form for add non-transformed food
|
Form for add basicfood
|
||||||
"""
|
"""
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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())
|
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||||
shuffle(clubs)
|
shuffle(clubs)
|
||||||
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
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:
|
class Meta:
|
||||||
model = BasicFood
|
model = BasicFood
|
||||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'is_active', 'was_eaten', 'allergens',)
|
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
|
||||||
widgets = {
|
widgets = {
|
||||||
"owner": Autocomplete(
|
"owner": Autocomplete(
|
||||||
model=Club,
|
model=Club,
|
||||||
attrs={"api_url": "/api/members/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):
|
class TransformedFoodForms(forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Form for add transformed food
|
Form for add transformedfood
|
||||||
"""
|
"""
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
|
|
||||||
self.fields['name'].required = True
|
self.fields['name'].required = True
|
||||||
self.fields['owner'].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
|
# Some example
|
||||||
self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
|
self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
|
||||||
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||||
shuffle(clubs)
|
shuffle(clubs)
|
||||||
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
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:
|
class Meta:
|
||||||
model = TransformedFood
|
model = TransformedFood
|
||||||
fields = ('name', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life')
|
fields = ('name', 'owner', 'order',)
|
||||||
widgets = {
|
widgets = {
|
||||||
"owner": Autocomplete(
|
"owner": Autocomplete(
|
||||||
model=Club,
|
model=Club,
|
||||||
attrs={"api_url": "/api/members/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
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
('member', '0011_profile_vss_charter_read'),
|
("member", "0013_auto_20240801_1436"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Allergen',
|
name="Allergen",
|
||||||
fields=[
|
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={
|
options={
|
||||||
'verbose_name': 'Allergen',
|
"verbose_name": "Allergen",
|
||||||
'verbose_name_plural': 'Allergens',
|
"verbose_name_plural": "Allergens",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Food',
|
name="Food",
|
||||||
fields=[
|
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",
|
||||||
('expiry_date', models.DateTimeField(verbose_name='expiry date')),
|
models.AutoField(
|
||||||
('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')),
|
auto_created=True,
|
||||||
('is_ready', models.BooleanField(default=False, verbose_name='is ready')),
|
primary_key=True,
|
||||||
('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')),
|
serialize=False,
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='owner')),
|
verbose_name="ID",
|
||||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_food.food_set+', to='contenttypes.ContentType')),
|
),
|
||||||
|
),
|
||||||
|
("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={
|
options={
|
||||||
'verbose_name': 'foods',
|
"verbose_name": "Food",
|
||||||
|
"verbose_name_plural": "Foods",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='BasicFood',
|
name="BasicFood",
|
||||||
fields=[
|
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)),
|
"food_ptr",
|
||||||
('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')),
|
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={
|
options={
|
||||||
'verbose_name': 'Basic food',
|
"verbose_name": "Basic food",
|
||||||
'verbose_name_plural': 'Basic foods',
|
"verbose_name_plural": "Basic foods",
|
||||||
},
|
},
|
||||||
bases=('food.food',),
|
bases=("food.food",),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='QRCode',
|
name="QRCode",
|
||||||
fields=[
|
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')),
|
"id",
|
||||||
('food_container', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='QR_code', to='food.Food', verbose_name='food container')),
|
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={
|
options={
|
||||||
'verbose_name': 'QR-code',
|
"verbose_name": "QR-code",
|
||||||
'verbose_name_plural': 'QR-codes',
|
"verbose_name_plural": "QR-codes",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TransformedFood',
|
name="TransformedFood",
|
||||||
fields=[
|
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')),
|
"food_ptr",
|
||||||
('is_active', models.BooleanField(default=True, verbose_name='is active')),
|
models.OneToOneField(
|
||||||
('ingredient', models.ManyToManyField(blank=True, related_name='transformed_ingredient_inv', to='food.Food', verbose_name='transformed ingredient')),
|
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={
|
options={
|
||||||
'verbose_name': 'Transformed food',
|
"verbose_name": "Transformed food",
|
||||||
'verbose_name_plural': 'Transformed foods',
|
"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.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from member.models import Club
|
|
||||||
from polymorphic.models import PolymorphicModel
|
from polymorphic.models import PolymorphicModel
|
||||||
|
from member.models import Club
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class Allergen(models.Model):
|
class Allergen(models.Model):
|
||||||
"""
|
"""
|
||||||
A list of allergen and alimentary restrictions
|
Allergen and alimentary restrictions
|
||||||
"""
|
"""
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
verbose_name=_('name'),
|
verbose_name=_('name'),
|
||||||
@ -44,16 +20,19 @@ class Allergen(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('Allergen')
|
verbose_name = _("Allergen")
|
||||||
verbose_name_plural = _('Allergens')
|
verbose_name_plural = _("Allergens")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Food(PolymorphicModel):
|
class Food(PolymorphicModel):
|
||||||
|
"""
|
||||||
|
Describe any type of food
|
||||||
|
"""
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
verbose_name=_('name'),
|
verbose_name=_("name"),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -67,7 +46,7 @@ class Food(PolymorphicModel):
|
|||||||
allergens = models.ManyToManyField(
|
allergens = models.ManyToManyField(
|
||||||
Allergen,
|
Allergen,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name=_('allergen'),
|
verbose_name=_('allergens'),
|
||||||
)
|
)
|
||||||
|
|
||||||
expiry_date = models.DateTimeField(
|
expiry_date = models.DateTimeField(
|
||||||
@ -75,41 +54,69 @@ class Food(PolymorphicModel):
|
|||||||
null=False,
|
null=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
was_eaten = models.BooleanField(
|
end_of_life = models.CharField(
|
||||||
default=False,
|
blank=True,
|
||||||
verbose_name=_('was eaten'),
|
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(
|
is_ready = models.BooleanField(
|
||||||
default=False,
|
|
||||||
verbose_name=_('is ready'),
|
verbose_name=_('is ready'),
|
||||||
|
max_length=255,
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = models.BooleanField(
|
order = models.CharField(
|
||||||
default=True,
|
blank=True,
|
||||||
verbose_name=_('is active'),
|
verbose_name=_('order'),
|
||||||
|
max_length=255,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
def update_allergens(self):
|
||||||
return super().save(force_insert, force_update, using, update_fields)
|
# 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:
|
class Meta:
|
||||||
verbose_name = _('food')
|
verbose_name = _('Food')
|
||||||
verbose_name = _('foods')
|
verbose_name_plural = _('Foods')
|
||||||
|
|
||||||
|
|
||||||
class BasicFood(Food):
|
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(
|
date_type = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
choices=(
|
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
|
@transaction.atomic
|
||||||
def update_allergens(self):
|
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
|
||||||
# update parents
|
created = self.pk is None
|
||||||
for parent in self.transformed_ingredient_inv.iterator():
|
if not created:
|
||||||
parent.update_allergens()
|
# 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
|
if ('old_allergens' in kwargs
|
||||||
def update_expiry_date(self):
|
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||||
# update parents
|
self.update_allergens()
|
||||||
for parent in self.transformed_ingredient_inv.iterator():
|
|
||||||
parent.update_expiry_date()
|
|
||||||
|
|
||||||
@transaction.atomic
|
# Expiry date
|
||||||
def update(self):
|
if ((self.expiry_date != old_food.expiry_date
|
||||||
self.update_allergens()
|
and self.date_type == 'DLC')
|
||||||
self.update_expiry_date()
|
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:
|
class Meta:
|
||||||
verbose_name = _('Basic food')
|
verbose_name = _('Basic food')
|
||||||
verbose_name_plural = _('Basic foods')
|
verbose_name_plural = _('Basic foods')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class TransformedFood(Food):
|
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(
|
creation_date = models.DateTimeField(
|
||||||
|
default=timezone.now,
|
||||||
verbose_name=_('creation date'),
|
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,
|
Food,
|
||||||
blank=True,
|
blank=True,
|
||||||
symmetrical=False,
|
symmetrical=False,
|
||||||
@ -169,58 +196,91 @@ class TransformedFood(Food):
|
|||||||
verbose_name=_('transformed ingredient'),
|
verbose_name=_('transformed ingredient'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Without microbiological analyzes, the storage time is 3 days
|
def check_cycle(self, ingredients, origin, checked):
|
||||||
shelf_life = models.DurationField(
|
for ingredient in ingredients:
|
||||||
verbose_name=_("shelf life"),
|
if ingredient == origin:
|
||||||
default=timedelta(days=3),
|
# 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
|
@transaction.atomic
|
||||||
def archive(self):
|
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
|
||||||
# When a meal are archived, if it was eaten, update ingredient fully used for this meal
|
created = self.pk is None
|
||||||
raise NotImplementedError
|
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
|
# Expiry date
|
||||||
def update_allergens(self):
|
update['expiry_date'] = (self.shelf_life != old_food.shelf_life
|
||||||
# When allergens are changed, simply update the parents' allergens
|
or self.creation_date != old_food.creation_date)
|
||||||
old_allergens = list(self.allergens.all())
|
if update['expiry_date']:
|
||||||
self.allergens.clear()
|
self.expiry_date = self.creation_date + self.shelf_life
|
||||||
for ingredient in self.ingredient.iterator():
|
# Unfortunately with the set method ingredients are already save,
|
||||||
self.allergens.set(self.allergens.union(ingredient.allergens.all()))
|
# 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()):
|
# it's preferable to keep a queryset but we allow list too
|
||||||
return
|
if type(kwargs['old_ingredients']) is list:
|
||||||
super().save()
|
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
|
if created:
|
||||||
for parent in self.transformed_ingredient_inv.iterator():
|
self.expiry_date = self.shelf_life + self.creation_date
|
||||||
parent.update_allergens()
|
|
||||||
|
|
||||||
@transaction.atomic
|
# We save here because we need pk for many-to-many relation
|
||||||
def update_expiry_date(self):
|
super().save(force_insert, force_update, using, update_fields)
|
||||||
# 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)
|
|
||||||
|
|
||||||
if old_expiry_date == self.expiry_date:
|
for child in self.ingredients.iterator():
|
||||||
return
|
self.allergens.set(self.allergens.union(child.allergens.all()))
|
||||||
super().save()
|
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
|
||||||
|
self.expiry_date = min(self.expiry_date, child.expiry_date)
|
||||||
# update parents
|
return super().save(force_insert, force_update, using, update_fields)
|
||||||
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)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('Transformed food')
|
verbose_name = _('Transformed food')
|
||||||
verbose_name_plural = _('Transformed foods')
|
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
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2 import A
|
|
||||||
|
|
||||||
from .models import TransformedFood
|
from .models import Food
|
||||||
|
|
||||||
|
|
||||||
class TransformedFoodTable(tables.Table):
|
class FoodTable(tables.Table):
|
||||||
name = tables.LinkColumn(
|
"""
|
||||||
'food:food_view',
|
List all foods.
|
||||||
args=[A('pk'), ],
|
"""
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransformedFood
|
model = Food
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
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" %}
|
{% extends "base.html" %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
|
Copyright (C) by BDE ENS Paris-Saclay
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% load i18n crispy_forms_tags %}
|
{% 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'
|
app_name = 'food'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.TransformedListView.as_view(), name='food_list'),
|
path('', views.FoodListView.as_view(), name='food_list'),
|
||||||
path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'),
|
path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||||
path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'),
|
path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
|
||||||
|
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
|
||||||
path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
|
||||||
path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'),
|
path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
|
||||||
path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'),
|
path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
|
||||||
path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'),
|
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
|
||||||
path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'),
|
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
|
||||||
path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
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
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.db import transaction
|
from datetime import timedelta
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.http import HttpResponseRedirect
|
from api.viewsets import is_regex
|
||||||
from django_tables2.views import MultiTableMixin
|
from django_tables2.views import MultiTableMixin
|
||||||
from django.urls import reverse
|
from django.db import transaction
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.http import HttpResponseRedirect, Http404
|
||||||
from django.views.generic import DetailView, UpdateView
|
from django.views.generic import DetailView, UpdateView, CreateView
|
||||||
from django.views.generic.list import ListView
|
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.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 Food, BasicFood, TransformedFood, QRCode
|
||||||
from .models import BasicFood, Food, QRCode, TransformedFood
|
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
|
||||||
from .tables import TransformedFoodTable
|
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
|
model = Food
|
||||||
template_name = 'food/add_ingredient_form.html'
|
tables = [FoodTable, FoodTable, FoodTable, ]
|
||||||
extra_context = {"title": _("Add the ingredient")}
|
extra_context = {"title": _('Food')}
|
||||||
form_class = AddIngredientForms
|
template_name = 'food/food_list.html'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
return super().get_queryset(**kwargs).distinct()
|
||||||
context["pk"] = self.kwargs["pk"]
|
|
||||||
return context
|
|
||||||
|
|
||||||
@transaction.atomic
|
def get_tables(self):
|
||||||
def form_valid(self, form):
|
bureau_role_pk = 4
|
||||||
form.instance.creater = self.request.user
|
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
|
||||||
food = Food.objects.get(pk=self.kwargs['pk'])
|
user=self.request.user, roles=bureau_role_pk).filter(
|
||||||
add_ingredient_form = AddIngredientForms(data=self.request.POST)
|
date_end__gte=timezone.now()))
|
||||||
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)
|
|
||||||
|
|
||||||
# We flip logic ""fully used = not is_active""
|
tables = [FoodTable] * (clubs.count() + 3)
|
||||||
food.is_active = not food.is_active
|
self.tables = tables
|
||||||
# Save the aliment and the allergens associed
|
tables = super().get_tables()
|
||||||
for transformed_pk in self.request.POST.getlist('ingredient'):
|
tables[0].prefix = 'search-'
|
||||||
transformed = TransformedFood.objects.get(pk=transformed_pk)
|
tables[1].prefix = 'open-'
|
||||||
if not transformed.is_ready:
|
tables[2].prefix = 'served-'
|
||||||
transformed.ingredient.add(food)
|
for i in range(clubs.count()):
|
||||||
transformed.update()
|
tables[i + 3].prefix = clubs[i].name
|
||||||
food.save()
|
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):
|
# check regex
|
||||||
return reverse('food:food_list')
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||||
|
prefix = '^' if valid_regex else ''
|
||||||
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
|
||||||
"""
|
| Q(**{f'owner__name{suffix}': prefix + pattern}))
|
||||||
A view to update a basic food
|
else:
|
||||||
"""
|
qs = qs.none()
|
||||||
model = BasicFood
|
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||||
form_class = BasicFoodForms
|
# table open
|
||||||
template_name = 'food/basicfood_form.html'
|
open_table = self.get_queryset().order_by('expiry_date').filter(
|
||||||
extra_context = {"title": _("Update an aliment")}
|
Q(polymorphic_ctype__model='transformedfood')
|
||||||
|
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
|
||||||
@transaction.atomic
|
expiry_date__lt=timezone.now(), end_of_life='').filter(
|
||||||
def form_valid(self, form):
|
PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||||
form.instance.creater = self.request.user
|
# table served
|
||||||
basic_food_form = BasicFoodForms(data=self.request.POST)
|
served_table = self.get_queryset().order_by('-pk').filter(
|
||||||
if not basic_food_form.is_valid():
|
end_of_life='', is_ready=True).exclude(
|
||||||
return self.form_invalid(form)
|
Q(polymorphic_ctype__model='basicfood',
|
||||||
|
basicfood__date_type='DLC',
|
||||||
ans = super().form_valid(form)
|
expiry_date__lte=timezone.now(),)
|
||||||
form.instance.update()
|
| Q(polymorphic_ctype__model='transformedfood',
|
||||||
return ans
|
expiry_date__lte=timezone.now(),
|
||||||
|
))
|
||||||
def get_success_url(self, **kwargs):
|
# tables club
|
||||||
self.object.refresh_from_db()
|
bureau_role_pk = 4
|
||||||
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
|
||||||
|
user=self.request.user, roles=bureau_role_pk).filter(
|
||||||
def get_context_data(self, **kwargs):
|
date_end__gte=timezone.now()))
|
||||||
context = super().get_context_data(**kwargs)
|
club_table = []
|
||||||
return context
|
for club in clubs:
|
||||||
|
club_table.append(self.get_queryset().order_by('expiry_date').filter(
|
||||||
|
owner=club, end_of_life='').filter(
|
||||||
class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
PermissionBackend.filter_queryset(self.request, Food, 'view')
|
||||||
"""
|
))
|
||||||
A view to see a food
|
return [search_table, open_table, served_table] + club_table
|
||||||
"""
|
|
||||||
model = Food
|
|
||||||
extra_context = {"title": _("Details of:")}
|
|
||||||
context_object_name = "food"
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
tables = context['tables']
|
||||||
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
# 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
|
return context
|
||||||
|
|
||||||
|
|
||||||
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
class QRCodeCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
||||||
#####################################################################
|
|
||||||
# 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)
|
|
||||||
#####################################################################
|
|
||||||
"""
|
"""
|
||||||
A view to add a basic food with a qrcode
|
A view to add 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
|
|
||||||
"""
|
"""
|
||||||
model = QRCode
|
model = QRCode
|
||||||
template_name = 'food/create_qrcode_form.html'
|
template_name = 'food/qrcode.html'
|
||||||
form_class = QRCodeForms
|
form_class = QRCodeForms
|
||||||
extra_context = {"title": _("Add a new QRCode")}
|
extra_context = {"title": _("Add a new QRCode")}
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
qrcode = kwargs["slug"]
|
qrcode = kwargs["slug"]
|
||||||
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
|
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:
|
else:
|
||||||
return super().get(*args, **kwargs)
|
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
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
form.instance.creater = self.request.user
|
|
||||||
qrcode_food_form = QRCodeForms(data=self.request.POST)
|
qrcode_food_form = QRCodeForms(data=self.request.POST)
|
||||||
if not qrcode_food_form.is_valid():
|
if not qrcode_food_form.is_valid():
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
# Save the qrcode
|
|
||||||
qrcode = form.save(commit=False)
|
qrcode = form.save(commit=False)
|
||||||
qrcode.qr_code_number = self.kwargs["slug"]
|
qrcode.qr_code_number = self.kwargs['slug']
|
||||||
qrcode._force_save = True
|
qrcode._force_save = True
|
||||||
qrcode.save()
|
qrcode.save()
|
||||||
qrcode.refresh_from_db()
|
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)
|
return super().form_valid(form)
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
self.object.refresh_from_db()
|
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):
|
def get_context_data(self, *args, **kwargs):
|
||||||
return QRCode(
|
context = super().get_context_data(*args, **kwargs)
|
||||||
qr_code_number=self.kwargs["slug"],
|
|
||||||
food_container_id=1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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
|
return context
|
||||||
|
|
||||||
|
|
||||||
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
"""
|
"""
|
||||||
A view to add a tranformed food
|
A view to add transformedfood
|
||||||
"""
|
"""
|
||||||
model = TransformedFood
|
model = TransformedFood
|
||||||
template_name = 'food/transformedfood_form.html'
|
|
||||||
form_class = TransformedFoodForms
|
form_class = TransformedFoodForms
|
||||||
extra_context = {"title": _("Add a new meal")}
|
extra_context = {"title": _("Add a meal")}
|
||||||
|
template_name = "food/food_update.html"
|
||||||
@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})
|
|
||||||
|
|
||||||
def get_sample_object(self):
|
def get_sample_object(self):
|
||||||
# We choose a club which may work or BDE else
|
# We choose a club which may work or BDE else
|
||||||
owner_id = 1
|
food = TransformedFood(
|
||||||
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(
|
|
||||||
name="",
|
name="",
|
||||||
owner_id=owner_id,
|
owner_id=1,
|
||||||
creation_date=timezone.now(),
|
|
||||||
expiry_date=timezone.now(),
|
expiry_date=timezone.now(),
|
||||||
|
is_ready=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
for membership in self.request.user.memberships.all():
|
||||||
context = super().get_context_data(**kwargs)
|
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
|
return food
|
||||||
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 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
|
model = TransformedFood
|
||||||
template_name = 'food/transformedfood_form.html'
|
fields = ['ingredients']
|
||||||
form_class = TransformedFoodForms
|
extra_context = {"title": _("Manage ingredients of:")}
|
||||||
extra_context = {'title': _('Update a meal')}
|
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
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
form.instance.creater = self.request.user
|
form.instance.creater = self.request.user
|
||||||
transformedfood_form = TransformedFoodForms(data=self.request.POST)
|
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||||
if not transformedfood_form.is_valid():
|
old_allergens = list(food.allergens.all()).copy()
|
||||||
return self.form_invalid(form)
|
|
||||||
|
|
||||||
|
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)
|
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
|
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):
|
def get_success_url(self, **kwargs):
|
||||||
self.object.refresh_from_db()
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**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
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
fields = ['arrival_date', 'date_type']
|
||||||
# We choose a club which should work
|
for field in fields:
|
||||||
for membership in self.request.user.memberships.all():
|
context["fields"].append((
|
||||||
club_id = membership.club.id
|
BasicFood._meta.get_field(field).verbose_name.capitalize(),
|
||||||
food = TransformedFood(
|
getattr(self.object, field)
|
||||||
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
|
|
||||||
return context
|
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):
|
class CustomAuthenticationForm(AuthenticationForm):
|
||||||
permission_mask = forms.ModelChoiceField(
|
permission_mask = forms.ModelChoiceField(
|
||||||
label=_("Permission mask"),
|
label=_("Permission mask"),
|
||||||
queryset=PermissionMask.objects.order_by("rank"),
|
queryset=PermissionMask.objects.order_by("-rank"),
|
||||||
empty_label=None,
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<!-- MODAL TO CROP THE IMAGE -->
|
<!-- 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-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-body">
|
<div class="modal-body-wrapper" style="width: 500px; height: 500px; padding: 16px;">
|
||||||
<img src="" id="modal-image" style="max-width: 100%;">
|
<div class="modal-body" style="width: 100%; height: 100%; padding: 0">
|
||||||
</div>
|
<img src="" id="modal-image" style="display: block; max-width: 100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div class="btn-group pull-left" role="group">
|
<div class="btn-group pull-left" role="group">
|
||||||
<button type="button" class="btn btn-default" id="js-zoom-in">
|
<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)
|
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
|
||||||
|
|
||||||
def test_logout(self):
|
def test_logout(self):
|
||||||
response = self.client.get(reverse("logout"))
|
response = self.client.post(reverse("logout"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_admin_index(self):
|
def test_admin_index(self):
|
||||||
|
@ -13,7 +13,7 @@ def register_note_urls(router, path):
|
|||||||
router.register(path + '/note', NotePolymorphicViewSet)
|
router.register(path + '/note', NotePolymorphicViewSet)
|
||||||
router.register(path + '/alias', AliasViewSet)
|
router.register(path + '/alias', AliasViewSet)
|
||||||
router.register(path + '/trust', TrustViewSet)
|
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/category', TemplateCategoryViewSet)
|
||||||
router.register(path + '/transaction/transaction', TransactionViewSet)
|
router.register(path + '/transaction/transaction', TransactionViewSet)
|
||||||
|
@ -294,3 +294,10 @@ searchbar.addEventListener("keyup", function (e) {
|
|||||||
if (firstMatch && e.key === "Enter")
|
if (firstMatch && e.key === "Enter")
|
||||||
firstMatch.click()
|
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>
|
</ul>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<select id="debit_type" class="form-control custom-select d-none">
|
<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>
|
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,10 @@
|
|||||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||||
from oauth2_provider.scopes import BaseScopes
|
from oauth2_provider.scopes import BaseScopes
|
||||||
from member.models import Club
|
from member.models import Club
|
||||||
|
from note.models import Alias
|
||||||
from note_kfet.middlewares import get_current_request
|
from note_kfet.middlewares import get_current_request
|
||||||
|
|
||||||
from .backends import PermissionBackend
|
from .backends import PermissionBackend
|
||||||
@ -17,25 +19,46 @@ class PermissionScopes(BaseScopes):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def get_all_scopes(self):
|
def get_all_scopes(self):
|
||||||
return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
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):
|
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
|
||||||
if not application:
|
if not application:
|
||||||
return []
|
return []
|
||||||
return [f"{p.id}_{p.membership.club.id}"
|
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||||
for t in Permission.PERMISSION_TYPES
|
for t in Permission.PERMISSION_TYPES
|
||||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||||
|
scopes.append('openid')
|
||||||
|
return scopes
|
||||||
|
|
||||||
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
|
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
|
||||||
if not application:
|
if not application:
|
||||||
return []
|
return []
|
||||||
return [f"{p.id}_{p.membership.club.id}"
|
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||||
|
scopes.append('openid')
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
|
||||||
class PermissionOAuth2Validator(OAuth2Validator):
|
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):
|
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -54,6 +77,8 @@ class PermissionOAuth2Validator(OAuth2Validator):
|
|||||||
if scope in scopes:
|
if scope in scopes:
|
||||||
valid_scopes.add(scope)
|
valid_scopes.add(scope)
|
||||||
|
|
||||||
request.scopes = valid_scopes
|
if 'openid' in scopes:
|
||||||
|
valid_scopes.add('openid')
|
||||||
|
|
||||||
|
request.scopes = valid_scopes
|
||||||
return valid_scopes
|
return valid_scopes
|
||||||
|
@ -19,6 +19,7 @@ EXCLUDED = [
|
|||||||
'oauth2_provider.accesstoken',
|
'oauth2_provider.accesstoken',
|
||||||
'oauth2_provider.grant',
|
'oauth2_provider.grant',
|
||||||
'oauth2_provider.refreshtoken',
|
'oauth2_provider.refreshtoken',
|
||||||
|
'oauth2_provider.idtoken',
|
||||||
'sessions.session',
|
'sessions.session',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ from django.utils import timezone
|
|||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from activity.models import Activity
|
from activity.models import Activity
|
||||||
from member.models import Club, Membership
|
from member.models import Club, Membership
|
||||||
from note.models import NoteUser
|
from note.models import NoteUser, NoteClub
|
||||||
from wei.models import WEIClub, Bus, WEIRegistration
|
from wei.models import WEIClub, Bus, WEIRegistration
|
||||||
|
|
||||||
|
|
||||||
@ -122,10 +122,13 @@ class TestPermissionDenied(TestCase):
|
|||||||
|
|
||||||
def test_validate_weiregistration(self):
|
def test_validate_weiregistration(self):
|
||||||
wei = WEIClub.objects.create(
|
wei = WEIClub.objects.create(
|
||||||
|
name="WEI Test",
|
||||||
membership_start=date.today(),
|
membership_start=date.today(),
|
||||||
date_start=date.today() + timedelta(days=1),
|
date_start=date.today() + timedelta(days=1),
|
||||||
date_end=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")
|
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)))
|
response = self.client.get(reverse("wei:validate_registration", kwargs=dict(pk=registration.pk)))
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
@ -171,7 +171,7 @@ class ScopesView(LoginRequiredMixin, TemplateView):
|
|||||||
available_scopes = scopes.get_available_scopes(app)
|
available_scopes = scopes.get_available_scopes(app)
|
||||||
context["scopes"][app] = OrderedDict()
|
context["scopes"][app] = OrderedDict()
|
||||||
items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes]
|
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:
|
for k, v in items:
|
||||||
context["scopes"][app][k] = v
|
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(
|
bde = models.CharField(
|
||||||
max_length=32,
|
max_length=32,
|
||||||
default='RavePartlist',
|
default='Diolistos',
|
||||||
choices=(
|
choices=(
|
||||||
|
('Diolistos', 'Diol[list]os'),
|
||||||
('RavePartlist', 'RavePart[list]'),
|
('RavePartlist', 'RavePart[list]'),
|
||||||
('SecretStorlist', 'SecretStor[list]'),
|
('SecretStorlist', 'SecretStor[list]'),
|
||||||
('TotalistSpies', 'Tota[list]Spies'),
|
('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
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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
|
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
'WEIForm', 'WEIRegistrationForm', 'WEIRegistration1AForm', 'WEIRegistration2AForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||||
]
|
]
|
||||||
|
@ -5,7 +5,7 @@ from bootstrap_datepicker_plus.widgets import DatePickerInput
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Q
|
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 django.utils.translation import gettext_lazy as _
|
||||||
from note.models import NoteSpecial, NoteUser
|
from note.models import NoteSpecial, NoteUser
|
||||||
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
|
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
|
||||||
@ -24,6 +24,7 @@ class WEIForm(forms.ModelForm):
|
|||||||
"membership_end": DatePickerInput(),
|
"membership_end": DatePickerInput(),
|
||||||
"date_start": DatePickerInput(),
|
"date_start": DatePickerInput(),
|
||||||
"date_end": DatePickerInput(),
|
"date_end": DatePickerInput(),
|
||||||
|
"caution_amount": AmountInput(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -39,7 +40,11 @@ class WEIRegistrationForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WEIRegistration
|
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 = {
|
widgets = {
|
||||||
"user": Autocomplete(
|
"user": Autocomplete(
|
||||||
User,
|
User,
|
||||||
@ -49,11 +54,30 @@ class WEIRegistrationForm(forms.ModelForm):
|
|||||||
'placeholder': 'Nom ...',
|
'placeholder': 'Nom ...',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
"birth_date": DatePickerInput(options={'minDate': '1900-01-01',
|
"birth_date": DatePickerInput(options={
|
||||||
'maxDate': '2100-01-01'}),
|
'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):
|
class WEIChooseBusForm(forms.Form):
|
||||||
bus = forms.ModelMultipleChoiceField(
|
bus = forms.ModelMultipleChoiceField(
|
||||||
queryset=Bus.objects,
|
queryset=Bus.objects,
|
||||||
@ -72,7 +96,7 @@ class WEIChooseBusForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
roles = forms.ModelMultipleChoiceField(
|
roles = forms.ModelMultipleChoiceField(
|
||||||
queryset=WEIRole.objects.filter(~Q(name="1A")),
|
queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")),
|
||||||
label=_("WEI Roles"),
|
label=_("WEI Roles"),
|
||||||
help_text=_("Select the roles that you are interested in."),
|
help_text=_("Select the roles that you are interested in."),
|
||||||
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
|
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
|
||||||
@ -81,13 +105,8 @@ class WEIChooseBusForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class WEIMembershipForm(forms.ModelForm):
|
class WEIMembershipForm(forms.ModelForm):
|
||||||
caution_check = forms.BooleanField(
|
|
||||||
required=False,
|
|
||||||
label=_("Caution check given"),
|
|
||||||
)
|
|
||||||
|
|
||||||
roles = forms.ModelMultipleChoiceField(
|
roles = forms.ModelMultipleChoiceField(
|
||||||
queryset=WEIRole.objects,
|
queryset=WEIRole.objects.filter(~Q(name="GC WEI")),
|
||||||
label=_("WEI Roles"),
|
label=_("WEI Roles"),
|
||||||
widget=CheckboxSelectMultiple(),
|
widget=CheckboxSelectMultiple(),
|
||||||
)
|
)
|
||||||
@ -121,6 +140,19 @@ class WEIMembershipForm(forms.ModelForm):
|
|||||||
required=False,
|
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
if 'team' in cleaned_data and cleaned_data["team"] is not None \
|
if 'team' in cleaned_data and cleaned_data["team"] is not None \
|
||||||
@ -132,21 +164,8 @@ class WEIMembershipForm(forms.ModelForm):
|
|||||||
model = WEIMembership
|
model = WEIMembership
|
||||||
fields = ('roles', 'bus', 'team',)
|
fields = ('roles', 'bus', 'team',)
|
||||||
widgets = {
|
widgets = {
|
||||||
"bus": Autocomplete(
|
"bus": RadioSelect(),
|
||||||
Bus,
|
"team": RadioSelect(),
|
||||||
attrs={
|
|
||||||
'api_url': '/api/wei/bus/',
|
|
||||||
'placeholder': 'Bus ...',
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"team": Autocomplete(
|
|
||||||
BusTeam,
|
|
||||||
attrs={
|
|
||||||
'api_url': '/api/wei/team/',
|
|
||||||
'placeholder': 'Équipe ...',
|
|
||||||
},
|
|
||||||
resetable=True,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,11 +2,11 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
|
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
|
||||||
from .wei2024 import WEISurvey2024
|
from .wei2025 import WEISurvey2025
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||||
]
|
]
|
||||||
|
|
||||||
CurrentSurvey = WEISurvey2024
|
CurrentSurvey = WEISurvey2025
|
||||||
|
@ -121,6 +121,13 @@ class WEISurveyAlgorithm:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_bus_information_form(cls):
|
||||||
|
"""
|
||||||
|
The class of the form to update the bus information.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class WEISurvey:
|
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"),
|
verbose_name=_("date end"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
caution_amount = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("caution amount"),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("WEI")
|
verbose_name = _("WEI")
|
||||||
verbose_name_plural = _("WEI")
|
verbose_name_plural = _("WEI")
|
||||||
@ -72,6 +77,15 @@ class Bus(models.Model):
|
|||||||
default=50,
|
default=50,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
club = models.OneToOneField(
|
||||||
|
Club,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="bus",
|
||||||
|
verbose_name=_("club"),
|
||||||
|
)
|
||||||
|
|
||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
@ -188,6 +202,16 @@ class WEIRegistration(models.Model):
|
|||||||
verbose_name=_("Caution check given")
|
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(
|
birth_date = models.DateField(
|
||||||
verbose_name=_("birth date"),
|
verbose_name=_("birth date"),
|
||||||
)
|
)
|
||||||
|
@ -98,7 +98,7 @@ class WEIRegistrationTable(tables.Table):
|
|||||||
if not hasperm:
|
if not hasperm:
|
||||||
return format_html("<span class='no-perm'></span>")
|
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')
|
text = _('Validate')
|
||||||
if record.fee > record.user.note.balance and not record.soge_credit:
|
if record.fee > record.user.note.balance and not record.soge_credit:
|
||||||
btn_class = 'btn-secondary'
|
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>
|
<dt class="col-xl-6">{% trans 'membership fee'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd>
|
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd>
|
||||||
{% else %}
|
{% 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>
|
<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 }}
|
<dd class="col-xl-6">{{ club.membership_fee_unpaid|pretty_money }}
|
||||||
<i class="fa fa-question-circle"
|
|
||||||
title="{% trans "The BDE membership is included in the WEI registration." %}"></i></dd>
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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 %}
|
{% if "note.view_note"|has_perm:club.note %}
|
||||||
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
|
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ club.note.balance | pretty_money }}</dd>
|
<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>
|
||||||
|
|
||||||
<div class="card-footer text-center">
|
<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 %}"
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=object.pk %}"
|
||||||
data-turbolinks="false">{% trans "Edit" %}</a>
|
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 %}"
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=object.pk %}"
|
||||||
data-turbolinks="false">{% trans "Add team" %}</a>
|
data-turbolinks="false">{% trans "Add team" %}</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,6 +18,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<div class="card-footer text-center">
|
<div class="card-footer text-center">
|
||||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=bus.pk %}"
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=bus.pk %}"
|
||||||
data-turbolinks="false">{% trans "Edit" %}</a>
|
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 %}"
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=bus.pk %}"
|
||||||
data-turbolinks="false">{% trans "Add team" %}</a>
|
data-turbolinks="false">{% trans "Add team" %}</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,9 +13,17 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ form.media }}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
if (window.jscolor && jscolor.install) {
|
||||||
|
jscolor.install();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -95,9 +95,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_validate_1a %}
|
{% if can_validate_1a %}
|
||||||
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
|
@ -143,25 +143,35 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if registration.user.note.balance < fee %}
|
<div class="alert {% if registration.user.note.balance < fee %}alert-danger{% else %}alert-success{% endif %}">
|
||||||
<div class="alert alert-danger">
|
<h5>{% trans "Required payments:" %}</h5>
|
||||||
{% with pretty_fee=fee|pretty_money %}
|
<ul>
|
||||||
{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
|
<li>{% blocktrans trimmed with amount=fee|pretty_money %}
|
||||||
The note don't have enough money ({{ balance }}, {{ pretty_fee }} required).
|
Membership fees: {{ amount }}
|
||||||
The registration may fail if you don't credit the note now.
|
{% endblocktrans %}</li>
|
||||||
{% endblocktrans %}
|
{% if registration.caution_type == 'note' %}
|
||||||
{% endwith %}
|
<li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
|
||||||
</div>
|
Deposit (by Note transaction): {{ amount }}
|
||||||
{% else %}
|
{% endblocktrans %}</li>
|
||||||
<div class="alert alert-success">
|
<li><strong>{% blocktrans trimmed with total=total_needed|pretty_money %}
|
||||||
{% blocktrans trimmed with pretty_fee=fee|pretty_money %}
|
Total needed: {{ total }}
|
||||||
The note has enough money ({{ pretty_fee }} required), the registration is possible.
|
{% endblocktrans %}</strong></li>
|
||||||
{% endblocktrans %}
|
{% else %}
|
||||||
</div>
|
<li>{% blocktrans trimmed with amount=club.caution_amount|pretty_money %}
|
||||||
{% endif %}
|
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 %}
|
{% 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">
|
<div class="alert alert-danger">
|
||||||
{% trans "The user didn't give her/his caution check." %}
|
{% trans "The user didn't give her/his caution check." %}
|
||||||
</div>
|
</div>
|
||||||
@ -200,4 +210,27 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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 %}
|
{% endblock %}
|
||||||
|
@ -6,8 +6,6 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
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 ..forms.surveys.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024
|
||||||
from ..models import Bus, WEIClub, WEIRegistration
|
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(max_score - score, 25) # Always less than 25 % of tolerance
|
||||||
|
|
||||||
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
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,
|
year=self.year + 1,
|
||||||
date_start=str(self.year + 1) + "-09-01",
|
date_start=str(self.year + 1) + "-09-01",
|
||||||
date_end=str(self.year + 1) + "-09-03",
|
date_end=str(self.year + 1) + "-09-03",
|
||||||
|
caution_amount=12000,
|
||||||
))
|
))
|
||||||
qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1)
|
qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1)
|
||||||
self.assertTrue(qs.exists())
|
self.assertTrue(qs.exists())
|
||||||
@ -160,6 +161,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
membership_end="2000-09-30",
|
membership_end="2000-09-30",
|
||||||
date_start="2000-09-01",
|
date_start="2000-09-01",
|
||||||
date_end="2000-09-03",
|
date_end="2000-09-03",
|
||||||
|
caution_amount=12000,
|
||||||
))
|
))
|
||||||
qs = WEIClub.objects.filter(name="Update WEI Test", id=self.wei.id)
|
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)
|
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.wei.pk)), 302, 200)
|
||||||
@ -318,6 +320,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
bus=[],
|
bus=[],
|
||||||
team=[],
|
team=[],
|
||||||
roles=[],
|
roles=[],
|
||||||
|
caution_type='check'
|
||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(response.context["membership_form"].is_valid())
|
self.assertFalse(response.context["membership_form"].is_valid())
|
||||||
@ -334,7 +337,8 @@ class TestWEIRegistration(TestCase):
|
|||||||
emergency_contact_phone='+33123456789',
|
emergency_contact_phone='+33123456789',
|
||||||
bus=[self.bus.id],
|
bus=[self.bus.id],
|
||||||
team=[self.team.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)
|
qs = WEIRegistration.objects.filter(user_id=user.id)
|
||||||
self.assertTrue(qs.exists())
|
self.assertTrue(qs.exists())
|
||||||
@ -354,6 +358,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
bus=[self.bus.id],
|
bus=[self.bus.id],
|
||||||
team=[self.team.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")).all()],
|
||||||
|
caution_type='check'
|
||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue("This user is already registered to this WEI." in str(response.context["form"].errors))
|
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],
|
team=[self.team.id],
|
||||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||||
information_json=self.registration.information_json,
|
information_json=self.registration.information_json,
|
||||||
|
caution_type='check'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M")
|
qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M")
|
||||||
self.assertTrue(qs.exists())
|
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
|
# Check the page when the registration is already validated
|
||||||
membership = WEIMembership(
|
membership = WEIMembership(
|
||||||
@ -560,11 +566,12 @@ class TestWEIRegistration(TestCase):
|
|||||||
team=[self.team.id],
|
team=[self.team.id],
|
||||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||||
information_json=self.registration.information_json,
|
information_json=self.registration.information_json,
|
||||||
|
caution_type='check'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L")
|
qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L")
|
||||||
self.assertTrue(qs.exists())
|
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
|
# Test invalid form
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -583,6 +590,7 @@ class TestWEIRegistration(TestCase):
|
|||||||
team=[],
|
team=[],
|
||||||
roles=[],
|
roles=[],
|
||||||
information_json=self.registration.information_json,
|
information_json=self.registration.information_json,
|
||||||
|
caution_type='check'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertFalse(response.context["membership_form"].is_valid())
|
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_bus = Bus.objects.create(wei=self.wei, name="Second bus")
|
||||||
second_team = BusTeam.objects.create(bus=second_bus, name="Second team", color=42)
|
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(
|
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,
|
bus=self.bus.pk,
|
||||||
team=second_team.pk,
|
team=second_team.pk,
|
||||||
credit_type=4, # Bank transfer
|
credit_type=4, # Bank transfer
|
||||||
@ -632,13 +640,14 @@ class TestWEIRegistration(TestCase):
|
|||||||
last_name="admin",
|
last_name="admin",
|
||||||
first_name="admin",
|
first_name="admin",
|
||||||
bank="Société générale",
|
bank="Société générale",
|
||||||
|
caution_check=True,
|
||||||
))
|
))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(response.context["form"].is_valid())
|
self.assertFalse(response.context["form"].is_valid())
|
||||||
self.assertTrue("This team doesn't belong to the given bus." in str(response.context["form"].errors))
|
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(
|
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,
|
bus=self.bus.pk,
|
||||||
team=self.team.pk,
|
team=self.team.pk,
|
||||||
credit_type=4, # Bank transfer
|
credit_type=4, # Bank transfer
|
||||||
@ -646,8 +655,10 @@ class TestWEIRegistration(TestCase):
|
|||||||
last_name="admin",
|
last_name="admin",
|
||||||
first_name="admin",
|
first_name="admin",
|
||||||
bank="Société générale",
|
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)
|
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
|
||||||
|
|
||||||
# Check if the membership is successfully created
|
# Check if the membership is successfully created
|
||||||
membership = WEIMembership.objects.filter(user_id=self.user.id, club=self.wei)
|
membership = WEIMembership.objects.filter(user_id=self.user.id, club=self.wei)
|
||||||
self.assertTrue(membership.exists())
|
self.assertTrue(membership.exists())
|
||||||
@ -767,7 +778,7 @@ class TestDefaultWEISurvey(TestCase):
|
|||||||
WEISurvey.update_form(None, None)
|
WEISurvey.update_form(None, None)
|
||||||
|
|
||||||
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
|
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):
|
class TestWeiAPI(TestAPI):
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
|
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
|
||||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
|
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, BusInformationUpdateView, \
|
||||||
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
|
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
|
||||||
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
|
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
|
||||||
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
|
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
|
||||||
@ -42,4 +42,5 @@ urlpatterns = [
|
|||||||
path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"),
|
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/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
|
||||||
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
|
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 os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import date, timedelta
|
from datetime import date
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q, Count
|
from django.db.models import Q, Count
|
||||||
from django.db.models.functions.text import Lower
|
from django.db.models.functions.text import Lower
|
||||||
|
from django import forms
|
||||||
from django.http import HttpResponse, Http404
|
from django.http import HttpResponse, Http404
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
@ -33,7 +35,7 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
|||||||
|
|
||||||
from .forms.registration import WEIChooseBusForm
|
from .forms.registration import WEIChooseBusForm
|
||||||
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
|
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
|
WEIMembershipForm, CurrentSurvey
|
||||||
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
|
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
|
||||||
WEIRegistration1ATable, WEIMembershipTable
|
WEIRegistration1ATable, WEIMembershipTable
|
||||||
@ -441,6 +443,10 @@ class BusTeamCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
self.object.refresh_from_db()
|
self.object.refresh_from_db()
|
||||||
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
|
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):
|
class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
"""
|
"""
|
||||||
@ -473,6 +479,10 @@ class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
|||||||
self.object.refresh_from_db()
|
self.object.refresh_from_db()
|
||||||
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
|
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):
|
class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
@ -500,7 +510,7 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
Register a new user to the WEI
|
Register a new user to the WEI
|
||||||
"""
|
"""
|
||||||
model = WEIRegistration
|
model = WEIRegistration
|
||||||
form_class = WEIRegistrationForm
|
form_class = WEIRegistration1AForm
|
||||||
extra_context = {"title": _("Register first year student to the WEI")}
|
extra_context = {"title": _("Register first year student to the WEI")}
|
||||||
|
|
||||||
def get_sample_object(self):
|
def get_sample_object(self):
|
||||||
@ -546,9 +556,17 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
def get_form(self, form_class=None):
|
def get_form(self, form_class=None):
|
||||||
form = super().get_form(form_class)
|
form = super().get_form(form_class)
|
||||||
form.fields["user"].initial = self.request.user
|
form.fields["user"].initial = self.request.user
|
||||||
del form.fields["first_year"]
|
|
||||||
del form.fields["caution_check"]
|
# Cacher les champs pendant l'inscription initiale
|
||||||
del form.fields["information_json"]
|
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
|
return form
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@ -586,7 +604,7 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
Register an old user to the WEI
|
Register an old user to the WEI
|
||||||
"""
|
"""
|
||||||
model = WEIRegistration
|
model = WEIRegistration
|
||||||
form_class = WEIRegistrationForm
|
form_class = WEIRegistration2AForm
|
||||||
extra_context = {"title": _("Register old student to the WEI")}
|
extra_context = {"title": _("Register old student to the WEI")}
|
||||||
|
|
||||||
def get_sample_object(self):
|
def get_sample_object(self):
|
||||||
@ -644,9 +662,19 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
form.fields["soge_credit"].disabled = True
|
form.fields["soge_credit"].disabled = True
|
||||||
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
||||||
|
|
||||||
del form.fields["caution_check"]
|
# Cacher les champs pendant l'inscription initiale
|
||||||
del form.fields["first_year"]
|
if "first_year" in form.fields:
|
||||||
del form.fields["information_json"]
|
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
|
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_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"]]
|
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
||||||
form.instance.information = information
|
form.instance.information = information
|
||||||
|
|
||||||
|
# Sauvegarder le type de caution
|
||||||
|
form.instance.caution_type = form.cleaned_data["caution_type"]
|
||||||
form.instance.save()
|
form.instance.save()
|
||||||
|
|
||||||
if 'treasury' in settings.INSTALLED_APPS:
|
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
|
# 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:
|
if today >= wei.date_start or today < wei.membership_start:
|
||||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
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)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["club"] = self.object.wei
|
context["club"] = self.object.wei
|
||||||
|
# Pass the validate parameter to the template
|
||||||
|
context["should_validate"] = self.should_validate
|
||||||
|
|
||||||
if self.object.is_validated:
|
if self.object.is_validated:
|
||||||
membership_form = self.get_membership_form(instance=self.object.membership,
|
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
|
# 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):
|
if not PermissionBackend.check_perm(self.request, 'wei.change_weiregistration_information_json', self.object):
|
||||||
del form.fields["information_json"]
|
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
|
return form
|
||||||
|
|
||||||
def get_membership_form(self, data=None, instance=None):
|
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_type"]
|
||||||
del membership_form.fields["credit_amount"]
|
del membership_form.fields["credit_amount"]
|
||||||
del membership_form.fields["first_name"]
|
del membership_form.fields["first_name"]
|
||||||
@ -759,10 +805,30 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
|||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
# If the membership is already validated, then we update the bus and the team (and the roles)
|
# If the membership is already validated, then we update the bus and the team (and the roles)
|
||||||
if form.instance.is_validated:
|
if form.instance.is_validated:
|
||||||
membership_form = self.get_membership_form(self.request.POST, form.instance.membership)
|
try:
|
||||||
if not membership_form.is_valid():
|
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)
|
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
|
# 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(
|
elif not form.instance.first_year and PermissionBackend.check_perm(
|
||||||
self.request, "wei.change_weiregistration_information_json", self.object):
|
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_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"]]
|
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
||||||
form.instance.information = information
|
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()
|
form.instance.save()
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
@ -787,14 +857,8 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
|||||||
survey = CurrentSurvey(self.object)
|
survey = CurrentSurvey(self.object)
|
||||||
if not survey.is_complete():
|
if not survey.is_complete():
|
||||||
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
|
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
|
||||||
if PermissionBackend.check_perm(self.request, "wei.add_weimembership", WEIMembership(
|
# On redirige vers la validation uniquement si c'est explicitement demandé (et stocké dans la vue)
|
||||||
club=self.object.wei,
|
if self.should_validate and self.request.user.has_perm("wei.add_weimembership"):
|
||||||
user=self.object.user,
|
|
||||||
date_start=date.today(),
|
|
||||||
date_end=date.today(),
|
|
||||||
fee=0,
|
|
||||||
registration=self.object,
|
|
||||||
)):
|
|
||||||
return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk})
|
return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk})
|
||||||
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.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")}
|
extra_context = {"title": _("Validate WEI registration")}
|
||||||
|
|
||||||
def get_sample_object(self):
|
def get_sample_object(self):
|
||||||
|
"""
|
||||||
|
Return a sample object for permission checking
|
||||||
|
"""
|
||||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||||
return WEIMembership(
|
return WEIMembership(
|
||||||
club=registration.wei,
|
|
||||||
user=registration.user,
|
user=registration.user,
|
||||||
date_start=date.today(),
|
club=registration.wei,
|
||||||
date_end=date.today() + timedelta(days=1),
|
date_start=registration.wei.date_start,
|
||||||
fee=0,
|
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,
|
registration=registration,
|
||||||
)
|
)
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
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()
|
today = date.today()
|
||||||
# We can't validate anyone once the WEI is started and before the membership start date
|
# 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:
|
if today >= wei.date_start or today < wei.membership_start:
|
||||||
@ -878,7 +947,14 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
date_start__gte=bde.membership_start,
|
date_start__gte=bde.membership_start,
|
||||||
).exists()
|
).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"]
|
form = context["form"]
|
||||||
if registration.soge_credit:
|
if registration.soge_credit:
|
||||||
@ -890,18 +966,41 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
|
|
||||||
def get_form_class(self):
|
def get_form_class(self):
|
||||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
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 WEIMembership1AForm
|
||||||
return WEIMembershipForm
|
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):
|
def get_form(self, form_class=None):
|
||||||
form = super().get_form(form_class)
|
form = super().get_form(form_class)
|
||||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||||
form.fields["last_name"].initial = registration.user.last_name
|
form.fields["last_name"].initial = registration.user.last_name
|
||||||
form.fields["first_name"].initial = registration.user.first_name
|
form.fields["first_name"].initial = registration.user.first_name
|
||||||
|
|
||||||
if "caution_check" in form.fields:
|
# Ajouter le champ caution_check uniquement pour les non-première année et le rendre obligatoire
|
||||||
form.fields["caution_check"].initial = registration.caution_check
|
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:
|
if registration.soge_credit:
|
||||||
form.fields["credit_type"].disabled = True
|
form.fields["credit_type"].disabled = True
|
||||||
@ -985,10 +1084,20 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
if credit_type is None or registration.soge_credit:
|
if credit_type is None or registration.soge_credit:
|
||||||
credit_amount = 0
|
credit_amount = 0
|
||||||
|
|
||||||
if not registration.soge_credit and user.note.balance + credit_amount < fee:
|
# Calculer le montant total nécessaire (frais + caution si transaction)
|
||||||
# Users must have money before registering to the WEI.
|
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',
|
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)
|
return super().form_invalid(form)
|
||||||
|
|
||||||
if credit_amount:
|
if credit_amount:
|
||||||
@ -1028,6 +1137,18 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
|||||||
membership.refresh_from_db()
|
membership.refresh_from_db()
|
||||||
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
|
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)
|
return super().form_valid(form)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
@ -1247,6 +1368,7 @@ class WEI1AListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableView):
|
|||||||
def get_queryset(self, filter_permissions=True, **kwargs):
|
def get_queryset(self, filter_permissions=True, **kwargs):
|
||||||
qs = super().get_queryset(filter_permissions, **kwargs)
|
qs = super().get_queryset(filter_permissions, **kwargs)
|
||||||
qs = qs.filter(first_year=True, membership__isnull=False)
|
qs = qs.filter(first_year=True, membership__isnull=False)
|
||||||
|
qs = qs.filter(wei=self.club)
|
||||||
qs = qs.order_by('-membership__bus')
|
qs = qs.order_by('-membership__bus')
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@ -1289,8 +1411,48 @@ class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
|
|||||||
if not wei.exists():
|
if not wei.exists():
|
||||||
raise Http404
|
raise Http404
|
||||||
wei = wei.get()
|
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...
|
# On cherche d'abord les 1A qui ont une inscription validée (membership) mais pas de bus
|
||||||
if qs.exists():
|
qs = WEIRegistration.objects.filter(
|
||||||
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk, ))
|
wei=wei,
|
||||||
return reverse_lazy('wei:wei_1A_list', args=(wei.pk, ))
|
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,
|
required=False,
|
||||||
help="""User will have their(s) wrapped generated,
|
help="""User will have their(s) wrapped generated,
|
||||||
all = all users
|
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
|
supersuser = all superusers
|
||||||
custom user1,user2,... = a list of username,
|
custom user1,user2,... = a list of username,
|
||||||
custom_id id1,id2,... = a list of user id""",
|
custom_id id1,id2,... = a list of user id""",
|
||||||
@ -70,15 +70,7 @@ class Command(BaseCommand):
|
|||||||
dest='create',
|
dest='create',
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options): # NOQA
|
||||||
# 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'
|
|
||||||
|
|
||||||
# Traitement des paramètres
|
# Traitement des paramètres
|
||||||
verb = options['verbosity']
|
verb = options['verbosity']
|
||||||
bde = []
|
bde = []
|
||||||
@ -89,11 +81,11 @@ class Command(BaseCommand):
|
|||||||
if options['bde_id']:
|
if options['bde_id']:
|
||||||
if bde:
|
if bde:
|
||||||
if verb >= 1:
|
if verb >= 1:
|
||||||
print(warning)
|
self.stdout.write(self.style.WARNING(
|
||||||
print(yellow + 'You already defined bde with their name !')
|
"WARNING\nYou already defined bde with their name !"))
|
||||||
if verb >= 0:
|
if verb >= 0:
|
||||||
print(abort)
|
self.stdout.write(self.style.ERROR("ABORT"))
|
||||||
return
|
exit(1)
|
||||||
bde_id = options['bde_id'].split(',')
|
bde_id = options['bde_id'].split(',')
|
||||||
bde = [Bde.objects.get(pk=i) for i in bde_id]
|
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]]
|
user = ['custom_id', [User.objects.get(pk=u) for u in user_id]]
|
||||||
else:
|
else:
|
||||||
if verb >= 1:
|
if verb >= 1:
|
||||||
print(warning)
|
self.sdtout.write(self.style.WARNING(
|
||||||
print(yellow + 'You user option is not recognized')
|
"WARNING\nYou user option is not recognized"))
|
||||||
if verb >= 0:
|
if verb >= 0:
|
||||||
print(abort)
|
self.stdout.write(self.style.ERROR("ABORT"))
|
||||||
return
|
exit(1)
|
||||||
|
|
||||||
club = []
|
club = []
|
||||||
if options['club']:
|
if options['club']:
|
||||||
@ -133,11 +125,11 @@ class Command(BaseCommand):
|
|||||||
club = ['custom_id', [Club.objects.get(pk=c) for c in club_id]]
|
club = ['custom_id', [Club.objects.get(pk=c) for c in club_id]]
|
||||||
else:
|
else:
|
||||||
if verb >= 1:
|
if verb >= 1:
|
||||||
print(warning)
|
self.stdout.write(self.style.WARNING(
|
||||||
print(yellow + 'You club option is not recognized')
|
"WARNING\nYou club option is not recognized"))
|
||||||
if verb >= 0:
|
if verb >= 0:
|
||||||
print(abort)
|
self.stdout.write(self.style.ERROR("ABORT"))
|
||||||
return
|
exit(1)
|
||||||
|
|
||||||
change = options['change']
|
change = options['change']
|
||||||
create = options['create']
|
create = options['create']
|
||||||
@ -145,72 +137,75 @@ class Command(BaseCommand):
|
|||||||
# check if parameters are sufficient for generate wrapped with the desired option
|
# check if parameters are sufficient for generate wrapped with the desired option
|
||||||
if not bde:
|
if not bde:
|
||||||
if verb >= 1:
|
if verb >= 1:
|
||||||
print(warning)
|
self.stdout.write(self.style.WARNING(
|
||||||
print(yellow + 'You have not selectionned a BDE !')
|
"WARNING\nYou have not selectionned a BDE !"))
|
||||||
if verb >= 0:
|
if verb >= 0:
|
||||||
print(abort)
|
self.stdout.write(self.style.ERROR("ABORT"))
|
||||||
return
|
exit(1)
|
||||||
if not (user or club):
|
if not (user or club):
|
||||||
if verb >= 1:
|
if verb >= 1:
|
||||||
print(warning)
|
self.stdout.write(self.style.WARNING(
|
||||||
print(yellow + 'No club or user selected !')
|
"WARNING\nNo club or user selected !"))
|
||||||
if verb >= 0:
|
if verb >= 0:
|
||||||
print(abort)
|
self.stdout.write(self.style.ERROR("ABORT"))
|
||||||
return
|
exit(1)
|
||||||
|
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('\033[1mOptions:\033[m')
|
self.stdout.write("Options:")
|
||||||
bde_str = ''
|
bde_str = ''
|
||||||
for b in bde:
|
for b in bde:
|
||||||
bde_str += str(b)
|
bde_str += str(b) + '\n'
|
||||||
print('BDE: ' + bde_str)
|
self.stdout.write("BDE: " + bde_str)
|
||||||
if user:
|
if user:
|
||||||
print('User: ' + user[0])
|
self.stdout.write('User: ' + user[0])
|
||||||
if club:
|
if club:
|
||||||
print('Club: ' + club[0])
|
self.stdout.write('Club: ' + club[0])
|
||||||
print('change: ' + str(change))
|
self.stdout.write('change: ' + str(change))
|
||||||
print('create: ' + str(create))
|
self.stdout.write('create: ' + str(create) + '\n')
|
||||||
print('')
|
|
||||||
if not (change or create):
|
if not (change or create):
|
||||||
if verb >= 1:
|
if verb >= 1:
|
||||||
print(warning)
|
self.stdout.write(self.style.WARNING(
|
||||||
print(yellow + 'change and create is set to false, none wrapped will be created')
|
"WARNING\nchange and create is set to false, none wrapped will be created"))
|
||||||
if verb >= 0:
|
if verb >= 0:
|
||||||
print(abort)
|
self.stdout.write(self.style.ERROR("ABORT"))
|
||||||
return
|
exit(1)
|
||||||
if verb >= 1 and change:
|
if verb >= 1 and change:
|
||||||
print(warning)
|
self.stdout.write(self.style.WARNING(
|
||||||
print(yellow + 'change is set to true, some wrapped may be replaced !')
|
"WARNING\nchange is set to true, some wrapped may be replaced !"))
|
||||||
if verb >= 1 and not create:
|
if verb >= 1 and not create:
|
||||||
print(warning)
|
self.stdout.write(self.style.WARNING(
|
||||||
print(yellow + 'create is set to false, wrapped will not be created !')
|
"WARNING\ncreate is set to false, wrapped will not be created !"))
|
||||||
if verb >= 3 or change or not create:
|
if verb >= 3 or change or not create:
|
||||||
a = str(input('\033[mContinue ? (y/n) ')).lower()
|
a = str(input('\033[mContinue ? (y/n) ')).lower()
|
||||||
if a in ['n', 'no', 'non', '0']:
|
if a in ['n', 'no', 'non', '0']:
|
||||||
if verb >= 0:
|
if verb >= 0:
|
||||||
print(abort)
|
self.stdout.write(self.style.ERROR("ABORT"))
|
||||||
return
|
exit(1)
|
||||||
|
|
||||||
note = self.convert_to_note(change, create, bde=bde, user=user, club=club, verb=verb)
|
note = self.convert_to_note(change, create, bde=bde, user=user, club=club, verb=verb)
|
||||||
if verb >= 1:
|
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)
|
global_data = self.global_data(bde, verb=verb)
|
||||||
if verb >= 1:
|
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)
|
unique_data = self.unique_data(bde, note, global_data=global_data, verb=verb)
|
||||||
if verb >= 1:
|
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)
|
self.make_wrapped(unique_data, note, bde, change, create, verb=verb)
|
||||||
if verb >= 1:
|
if verb >= 1:
|
||||||
print(green + "The wrapped has been generated !")
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
"The wrapped has been generated !"))
|
||||||
if verb >= 0:
|
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): # NOQA
|
||||||
|
|
||||||
def convert_to_note(self, change, create, bde=None, user=None, club=None, verb=1):
|
|
||||||
notes = []
|
notes = []
|
||||||
for b in bde:
|
for b in bde:
|
||||||
note_for_bde = Note.objects.filter(pk__lte=-1)
|
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)
|
note_for_bde = self.filter_note(b, note_for_bde, change, create, verb=verb)
|
||||||
notes.append(note_for_bde)
|
notes.append(note_for_bde)
|
||||||
if verb >= 2:
|
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
|
return notes
|
||||||
|
|
||||||
def global_data(self, bde, verb=1):
|
def global_data(self, bde, verb=1): # NOQA
|
||||||
data = {}
|
data = {}
|
||||||
for b in bde:
|
for b in bde:
|
||||||
if b.name == 'Rave Part[list]':
|
if b.name == 'Rave Part[list]':
|
||||||
if verb >= 2:
|
if verb >= 2:
|
||||||
print("Begin to make global data")
|
self.stdout.write("Begin to make global data")
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('nb_transaction')
|
self.stdout.write("nb_transaction")
|
||||||
# nb total de transactions
|
# nb total de transactions
|
||||||
data['nb_transaction'] = Transaction.objects.filter(
|
data['nb_transaction'] = Transaction.objects.filter(
|
||||||
created_at__gte=b.date_start,
|
created_at__gte=b.date_start,
|
||||||
@ -271,7 +266,7 @@ class Command(BaseCommand):
|
|||||||
valid=True).count()
|
valid=True).count()
|
||||||
|
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('nb_vieux_con')
|
self.stdout.write("nb_vieux_con")
|
||||||
# nb total de vielleux con·ne·s derrière le bar
|
# nb total de vielleux con·ne·s derrière le bar
|
||||||
button_id = [2884, 2585]
|
button_id = [2884, 2585]
|
||||||
transactions = Transaction.objects.filter(
|
transactions = Transaction.objects.filter(
|
||||||
@ -286,7 +281,7 @@ class Command(BaseCommand):
|
|||||||
data['nb_vieux_con'] = q
|
data['nb_vieux_con'] = q
|
||||||
|
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('nb_soiree')
|
self.stdout.write("nb_soiree")
|
||||||
# nb total de soirée
|
# nb total de soirée
|
||||||
a_type_id = [1, 2, 4, 5, 7, 10]
|
a_type_id = [1, 2, 4, 5, 7, 10]
|
||||||
data['nb_soiree'] = Activity.objects.filter(
|
data['nb_soiree'] = Activity.objects.filter(
|
||||||
@ -296,7 +291,7 @@ class Command(BaseCommand):
|
|||||||
activity_type__pk__in=a_type_id).count()
|
activity_type__pk__in=a_type_id).count()
|
||||||
|
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('pots, nb_entree_pot')
|
self.stdout.write('pots, nb_entree_pot')
|
||||||
# nb d'entrée totale aux pots
|
# nb d'entrée totale aux pots
|
||||||
pot_id = [1, 4, 10]
|
pot_id = [1, 4, 10]
|
||||||
pots = Activity.objects.filter(
|
pots = Activity.objects.filter(
|
||||||
@ -310,7 +305,7 @@ class Command(BaseCommand):
|
|||||||
data['nb_entree_pot'] += Entry.objects.filter(activity=pot).count()
|
data['nb_entree_pot'] += Entry.objects.filter(activity=pot).count()
|
||||||
|
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('top3_buttons')
|
self.stdout.write('top3_buttons')
|
||||||
# top 3 des boutons les plus cliqués
|
# top 3 des boutons les plus cliqués
|
||||||
transactions = Transaction.objects.filter(
|
transactions = Transaction.objects.filter(
|
||||||
created_at__gte=b.date_start,
|
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]
|
data['top3_buttons'] = list(sorted(d.items(), key=lambda item: item[1], reverse=True))[:3]
|
||||||
|
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('class_conso_all')
|
self.stdout.write('class_conso_all')
|
||||||
# le classement des plus gros consommateurs (BDE + club)
|
# le classement des plus gros consommateurs (BDE + club)
|
||||||
transactions = Transaction.objects.filter(
|
transactions = Transaction.objects.filter(
|
||||||
created_at__gte=b.date_start,
|
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))
|
data['class_conso_all'] = dict(sorted(d.items(), key=lambda item: item[1], reverse=True))
|
||||||
|
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
print('class_conso_bde')
|
self.stdout.write('class_conso_bde')
|
||||||
# le classement des plus gros consommateurs BDE
|
# le classement des plus gros consommateurs BDE
|
||||||
transactions = Transaction.objects.filter(
|
transactions = Transaction.objects.filter(
|
||||||
created_at__gte=b.date_start,
|
created_at__gte=b.date_start,
|
||||||
@ -368,11 +363,10 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# make your wrapped or reuse previous wrapped
|
# make your wrapped or reuse previous wrapped
|
||||||
raise NotImplementedError("The BDE: {bde_name} has not personalized wrapped, make it !"
|
raise NotImplementedError(f"The BDE: {b.name} has not personalized wrapped, make it !")
|
||||||
.format(bde_name=b.name))
|
|
||||||
return data
|
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 = []
|
data = []
|
||||||
for i in range(len(bde)):
|
for i in range(len(bde)):
|
||||||
data_bde = []
|
data_bde = []
|
||||||
@ -380,8 +374,7 @@ class Command(BaseCommand):
|
|||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
total = len(note[i])
|
total = len(note[i])
|
||||||
current = 0
|
current = 0
|
||||||
print('Make {nb} data for wrapped sponsored by {bde}'
|
self.stdout.write(f"Make {total} data for wrapped sponsored by {bde[i].name}")
|
||||||
.format(nb=total, bde=bde[i].name))
|
|
||||||
for n in note[i]:
|
for n in note[i]:
|
||||||
d = {}
|
d = {}
|
||||||
if 'user' in n.__dir__():
|
if 'user' in n.__dir__():
|
||||||
@ -542,12 +535,11 @@ class Command(BaseCommand):
|
|||||||
data_bde.append(json.dumps(d))
|
data_bde.append(json.dumps(d))
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
current += 1
|
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:
|
else:
|
||||||
# make your wrapped or reuse previous wrapped
|
# make your wrapped or reuse previous wrapped
|
||||||
raise NotImplementedError("The BDE: {bde_name} has not personalized wrapped, make it !"
|
raise NotImplementedError(f"The BDE: {bde[i].name} has not personalized wrapped, make it !")
|
||||||
.format(bde_name=bde[i].name))
|
|
||||||
data.append(data_bde)
|
data.append(data_bde)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@ -557,7 +549,7 @@ class Command(BaseCommand):
|
|||||||
total = 0
|
total = 0
|
||||||
for n in note:
|
for n in note:
|
||||||
total += len(n)
|
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 i in range(len(bde)):
|
||||||
for j in range(len(note[i])):
|
for j in range(len(note[i])):
|
||||||
if create and not Wrapped.objects.filter(bde=bde[i], note=note[i][j]):
|
if create and not Wrapped.objects.filter(bde=bde[i], note=note[i][j]):
|
||||||
@ -572,7 +564,7 @@ class Command(BaseCommand):
|
|||||||
w.save()
|
w.save()
|
||||||
if verb >= 3:
|
if verb >= 3:
|
||||||
current += 1
|
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
|
return
|
||||||
|
|
||||||
def filter_note(self, bde, note, change, create, verb=1):
|
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 d1 = document.getElementById("consumer");
|
||||||
let d2 = document.getElementById("creditor");
|
let d2 = document.getElementById("creditor");
|
||||||
if (con) { d1.textContent = {{ big_consumer | safe }}[0] + " " + gettext("with") + " " + {{ big_consumer | safe}}[1] + "€";}
|
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] + "€";}
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -6,17 +6,24 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div id="wrapped_tables">
|
||||||
<div class="col-md-10">
|
{% if tables|length > 0 %}
|
||||||
<div class="card card-border shadow">
|
<div class="card bg-light mb-3">
|
||||||
<div class="card-header text-center">
|
<h3 class="card-header text-center">
|
||||||
<h5> {{ title }}</h5>
|
{% trans "My wrapped" %}
|
||||||
</div>
|
</h3>
|
||||||
<div class="card-body px-0 py-0" id="wrapped_table">
|
{% render_table tables.1 %}
|
||||||
{% render_table table %}
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
{% 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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -25,7 +32,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
let club_not_public = {{ club_not_public }};
|
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'));}
|
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() {
|
function refreshTable() {
|
||||||
$("#wrapped_table").load(location.pathname + " #wrapped_table");
|
$("#wrapped_tables").load(location.pathname + " #wrapped_tables");
|
||||||
}
|
}
|
||||||
|
|
||||||
function copylink(id) {
|
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.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView
|
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.backends import PermissionBackend
|
||||||
from permission.views import ProtectQuerysetMixin
|
from permission.views import ProtectQuerysetMixin
|
||||||
|
|
||||||
@ -14,21 +15,29 @@ from .models import Wrapped
|
|||||||
from .tables import WrappedTable
|
from .tables import WrappedTable
|
||||||
|
|
||||||
|
|
||||||
class WrappedListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
class WrappedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||||
"""
|
"""
|
||||||
Display all Wrapped, and classify by year
|
Display all Wrapped, and classify by year
|
||||||
"""
|
"""
|
||||||
model = Wrapped
|
model = Wrapped
|
||||||
table_class = WrappedTable
|
tables = [
|
||||||
|
lambda data: WrappedTable(data, prefix="public-"),
|
||||||
|
lambda data: WrappedTable(data, prefix="personnal-"),
|
||||||
|
]
|
||||||
template_name = 'wrapped/wrapped_list.html'
|
template_name = 'wrapped/wrapped_list.html'
|
||||||
extra_context = {'title': _("List of wrapped")}
|
extra_context = {'title': _("List of wrapped")}
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
return super().get_queryset(**kwargs).distinct()
|
return super().get_queryset(**kwargs).distinct()
|
||||||
|
|
||||||
def get_table_data(self):
|
def get_tables_data(self):
|
||||||
return Wrapped.objects.filter(PermissionBackend.filter_queryset(
|
return [
|
||||||
self.request, Wrapped, "change", field='public')).distinct().order_by("-bde__date_start")
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**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)
|
* Activité concernée (clé étrangère)
|
||||||
* Nom de famille
|
* Nom de famille
|
||||||
* Prénom
|
* Prénom
|
||||||
|
* École
|
||||||
* Note de la personne ayant invité
|
* Note de la personne ayant invité
|
||||||
|
|
||||||
Certaines contraintes s'appliquent :
|
Certaines contraintes s'appliquent :
|
||||||
|
@ -19,8 +19,9 @@ Le modèle regroupe :
|
|||||||
* Propriétaire (doit-être un Club)
|
* Propriétaire (doit-être un Club)
|
||||||
* Allergènes (ManyToManyField)
|
* Allergènes (ManyToManyField)
|
||||||
* date d'expiration
|
* date d'expiration
|
||||||
* a été mangé (booléen)
|
* fin de vie
|
||||||
* est prêt (booléen)
|
* est prêt (booléen)
|
||||||
|
* consigne (pour les GCKs)
|
||||||
|
|
||||||
BasicFood
|
BasicFood
|
||||||
~~~~~~~~~
|
~~~~~~~~~
|
||||||
@ -40,7 +41,7 @@ Les TransformedFood correspondent aux produits préparés à la Kfet. Ils peuven
|
|||||||
|
|
||||||
Le modèle regroupe :
|
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)
|
* Ingrédients (ManyToManyField vers Food)
|
||||||
* Date de création
|
* Date de création
|
||||||
* Champs de Food
|
* Champs de Food
|
||||||
|
@ -12,8 +12,10 @@ Applications de la Note Kfet 2020
|
|||||||
../api/index
|
../api/index
|
||||||
registration
|
registration
|
||||||
logs
|
logs
|
||||||
|
food
|
||||||
treasury
|
treasury
|
||||||
wei
|
wei
|
||||||
|
wrapped
|
||||||
|
|
||||||
La Note Kfet 2020 est un projet Django, décomposé en applications.
|
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,
|
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.
|
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>`_
|
* `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...
|
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>`_ :
|
* `Treasury <treasury>`_ :
|
||||||
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques...
|
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques...
|
||||||
* `WEI <wei>`_ :
|
* `WEI <wei>`_ :
|
||||||
Interface de gestion du 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',
|
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
||||||
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
|
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
|
||||||
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
'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``,
|
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'))
|
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é.
|
L'OAuth2 est désormais prêt à être utilisé.
|
||||||
|
|
||||||
|
|
||||||
|
@ -183,6 +183,7 @@ Contributeur⋅rices
|
|||||||
* korenst1
|
* korenst1
|
||||||
* nicomarg
|
* nicomarg
|
||||||
* PAC
|
* PAC
|
||||||
|
* Quark
|
||||||
* ÿnérant
|
* ÿ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
|
Configuration des tâches récurrentes
|
||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
|
@ -136,7 +136,7 @@ de diffusion utiles.
|
|||||||
Faîtes attention, donc où la sortie est stockée.
|
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``,
|
* ``--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
|
``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
|
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.
|
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
|
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.
|
malheureusement pas aussi simple que de simplement supposer que ces listes sont exhaustives.
|
||||||
|
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