mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-07-22 00:49:11 +02:00
Compare commits
121 Commits
export_mem
...
c30403d44f
Author | SHA1 | Date | |
---|---|---|---|
c30403d44f | |||
f03c13a4b8 | |||
b1fa1c2cdd | |||
a273dc3eef | |||
852651d126 | |||
3af35dc0fc | |||
4380414c6b | |||
a94c937c6a | |||
0a261e6ad5 | |||
ab9329f62b | |||
b97b79e2ea | |||
483ea26f02 | |||
695ce63e08 | |||
79f50c27f1 | |||
5989721bc9 | |||
bcc3e7cc53 | |||
608804db30 | |||
82a06c29dd | |||
cf9d208586 | |||
432f50e49a | |||
883589e08c | |||
c36f8c25a2 | |||
8783a63d7f | |||
4cc43fe4b6 | |||
b7c0986a5f | |||
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 | |||
6c63c6417c | |||
4563b2b640 | |||
e6f3084588 | |||
145e55da75 | |||
d3ba95cdca | |||
8ffb0ebb56 | |||
5038af9e34 | |||
819b4214c9 | |||
b8a93b0b75 |
@ -21,3 +21,6 @@ EMAIL_PASSWORD=CHANGE_ME
|
||||
# Wiki configuration
|
||||
WIKI_USER=NoteKfet2020
|
||||
WIKI_PASSWORD=
|
||||
|
||||
# OIDC
|
||||
OIDC_RSA_PRIVATE_KEY=CHANGE_ME
|
||||
|
@ -8,7 +8,7 @@ variables:
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
|
||||
# Ubuntu 22.04
|
||||
py310-django42:
|
||||
py310-django52:
|
||||
stage: test
|
||||
image: ubuntu:22.04
|
||||
before_script:
|
||||
@ -22,10 +22,10 @@ py310-django42:
|
||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||
python3-bs4 python3-setuptools tox texlive-xetex
|
||||
script: tox -e py310-django42
|
||||
script: tox -e py310-django52
|
||||
|
||||
# Debian Bookworm
|
||||
py311-django42:
|
||||
py311-django52:
|
||||
stage: test
|
||||
image: debian:bookworm
|
||||
before_script:
|
||||
@ -37,7 +37,7 @@ py311-django42:
|
||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||
python3-bs4 python3-setuptools tox texlive-xetex
|
||||
script: tox -e py311-django42
|
||||
script: tox -e py311-django52
|
||||
|
||||
linters:
|
||||
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
|
||||
```
|
||||
|
||||
6. Enjoy :
|
||||
6. (Optionnel) **Création d'une clé privée OpenID Connect**
|
||||
|
||||
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
||||
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et copier la clé dans .env dans le champ
|
||||
`OIDC_RSA_PRIVATE_KEY`.
|
||||
|
||||
7. Enjoy :
|
||||
|
||||
```bash
|
||||
(env)$ ./manage.py runserver 0.0.0.0:8000
|
||||
@ -228,7 +234,13 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
|
||||
(env)$ ./manage.py check # pas de bêtise qui traine
|
||||
(env)$ ./manage.py migrate
|
||||
|
||||
7. *Enjoy \o/*
|
||||
7. **Création d'une clé privée OpenID Connect**
|
||||
|
||||
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
||||
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner le champ
|
||||
`OIDC_RSA_PRIVATE_KEY` dans le .env (par défaut `/var/secrets/oidc.key`).
|
||||
|
||||
8. *Enjoy \o/*
|
||||
|
||||
### Installation avec Docker
|
||||
|
||||
|
@ -35,7 +35,7 @@ class GuestAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for Guest
|
||||
"""
|
||||
list_display = ('last_name', 'first_name', 'activity', 'inviter')
|
||||
list_display = ('last_name', 'first_name', 'school', 'activity', 'inviter')
|
||||
form = GuestForm
|
||||
|
||||
|
||||
|
@ -51,9 +51,9 @@ class GuestViewSet(ReadProtectedModelViewSet):
|
||||
queryset = Guest.objects.order_by('id')
|
||||
serializer_class = GuestSerializer
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
|
||||
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'school', 'inviter', 'inviter__alias__name',
|
||||
'inviter__alias__normalized_name', ]
|
||||
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
|
||||
search_fields = ['$activity__name', '$last_name', '$first_name', '$school', '$inviter__user__email', '$inviter__alias__name',
|
||||
'$inviter__alias__normalized_name', ]
|
||||
|
||||
|
||||
|
@ -107,7 +107,7 @@ class GuestForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Guest
|
||||
fields = ('last_name', 'first_name', 'inviter', )
|
||||
fields = ('last_name', 'first_name', 'school', 'inviter', )
|
||||
widgets = {
|
||||
"inviter": Autocomplete(
|
||||
NoteUser,
|
||||
|
18
apps/activity/migrations/0006_guest_school.py
Normal file
18
apps/activity/migrations/0006_guest_school.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.20 on 2025-03-25 09:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("activity", "0005_alter_opener_options_alter_opener_opener"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="guest",
|
||||
name="school",
|
||||
field=models.CharField(default="", max_length=255, verbose_name="school"),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
19
apps/activity/migrations/0007_alter_guest_activity.py
Normal file
19
apps/activity/migrations/0007_alter_guest_activity.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.20 on 2025-05-08 19:07
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('activity', '0006_guest_school'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='guest',
|
||||
name='activity',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='activity.activity'),
|
||||
),
|
||||
]
|
@ -201,7 +201,8 @@ class Entry(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
|
||||
if qs.exists():
|
||||
raise ValidationError(_("Already entered on ") + _("{:%Y-%m-%d %H:%M:%S}").format(qs.get().time, ))
|
||||
raise ValidationError(_("Already entered on ")
|
||||
+ _("{:%Y-%m-%d %H:%M:%S}").format(timezone.localtime(qs.get().time), ))
|
||||
|
||||
if self.guest:
|
||||
self.note = self.guest.inviter
|
||||
@ -233,7 +234,7 @@ class Guest(models.Model):
|
||||
"""
|
||||
activity = models.ForeignKey(
|
||||
Activity,
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
@ -247,6 +248,11 @@ class Guest(models.Model):
|
||||
verbose_name=_("first name"),
|
||||
)
|
||||
|
||||
school = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("school"),
|
||||
)
|
||||
|
||||
inviter = models.ForeignKey(
|
||||
NoteUser,
|
||||
on_delete=models.PROTECT,
|
||||
|
@ -51,11 +51,11 @@ class GuestTable(tables.Table):
|
||||
}
|
||||
model = Guest
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ("last_name", "first_name", "inviter", )
|
||||
fields = ("last_name", "first_name", "inviter", "school")
|
||||
|
||||
def render_entry(self, record):
|
||||
if record.has_entry:
|
||||
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
|
||||
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(timezone.localtime(record.entry.time))))
|
||||
return mark_safe('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> '
|
||||
'{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize()))
|
||||
|
||||
|
@ -95,5 +95,23 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
errMsg(xhr.responseJSON);
|
||||
});
|
||||
});
|
||||
$("#delete_activity").click(function () {
|
||||
if (!confirm("{% trans 'Are you sure you want to delete this activity?' %}")) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: "/api/activity/activity/{{ activity.pk }}/",
|
||||
type: "DELETE",
|
||||
headers: {
|
||||
"X-CSRFTOKEN": CSRF_TOKEN
|
||||
}
|
||||
}).done(function () {
|
||||
addMsg("{% trans 'Activity deleted' %}", "success");
|
||||
window.location.href = "/activity/"; // Redirige vers la liste des activités
|
||||
}).fail(function (xhr) {
|
||||
errMsg(xhr.responseJSON);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -38,6 +38,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</a>
|
||||
|
||||
<input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
|
||||
<button id="trigger" class="btn btn-secondary">Click me !</button>
|
||||
|
||||
<hr>
|
||||
|
||||
@ -63,15 +64,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
refreshBalance();
|
||||
}
|
||||
|
||||
function process_qrcode() {
|
||||
let name = alias_obj.val();
|
||||
$.get("/api/note/note?search=" + name + "&format=json").done(
|
||||
function (res) {
|
||||
let note = res.results[0];
|
||||
$.post("/api/activity/entry/?format=json", {
|
||||
csrfmiddlewaretoken: CSRF_TOKEN,
|
||||
activity: {{ activity.id }},
|
||||
note: note.id,
|
||||
guest: null
|
||||
}).done(function () {
|
||||
addMsg(interpolate(gettext(
|
||||
"Entry made for %s whose balance is %s €"),
|
||||
[note.name, note.balance / 100]), "success", 4000);
|
||||
reloadTable(true);
|
||||
}).fail(function (xhr) {
|
||||
errMsg(xhr.responseJSON, 4000);
|
||||
});
|
||||
}).fail(function (xhr) {
|
||||
errMsg(xhr.responseJSON, 4000);
|
||||
});
|
||||
}
|
||||
|
||||
alias_obj.keyup(function(event) {
|
||||
let code = event.originalEvent.keyCode
|
||||
if (65 <= code <= 122 || code === 13) {
|
||||
debounce(reloadTable)()
|
||||
}
|
||||
if (code === 0)
|
||||
process_qrcode();
|
||||
});
|
||||
|
||||
$(document).ready(init);
|
||||
|
||||
alias_obj2 = document.getElementById("alias");
|
||||
$("#trigger").click(function (e) {
|
||||
addMsg("Clicked", "success", 1000);
|
||||
alias_obj.val(alias_obj.val() + "\0");
|
||||
alias_obj2.dispatchEvent(new KeyboardEvent('keyup'));
|
||||
})
|
||||
function init() {
|
||||
$(".table-row").click(function (e) {
|
||||
let target = e.target.parentElement;
|
||||
@ -168,4 +200,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
@ -70,7 +70,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% if ".change_"|has_perm:activity %}
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_update' pk=activity.pk %}" data-turbolinks="false"> {% trans "edit"|capfirst %}</a>
|
||||
{% endif %}
|
||||
{% if activity.activity_type.can_invite and not activity_started %}
|
||||
{% if not activity.valid and ".delete_"|has_perm:activity %}
|
||||
<a class="btn btn-danger btn-sm my-1" id="delete_activity"> {% trans "delete"|capfirst %} </a>
|
||||
{% endif %}
|
||||
{% if activity.activity_type.can_invite and not activity_started and activity.valid %}
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_invite' pk=activity.pk %}" data-turbolinks="false"> {% trans "Invite" %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
@ -50,6 +50,7 @@ class TestActivities(TestCase):
|
||||
inviter=self.user.note,
|
||||
last_name="GUEST",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
)
|
||||
|
||||
def test_activity_list(self):
|
||||
@ -156,6 +157,7 @@ class TestActivities(TestCase):
|
||||
inviter=self.user.note.id,
|
||||
last_name="GUEST2",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -167,6 +169,7 @@ class TestActivities(TestCase):
|
||||
inviter=self.user.note.id,
|
||||
last_name="GUEST2",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
))
|
||||
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
|
||||
|
||||
@ -200,6 +203,7 @@ class TestActivityAPI(TestAPI):
|
||||
inviter=self.user.note,
|
||||
last_name="GUEST",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
)
|
||||
|
||||
self.entry = Entry.objects.create(
|
||||
|
@ -15,4 +15,5 @@ urlpatterns = [
|
||||
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
|
||||
path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
|
||||
path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'),
|
||||
path('<int:pk>/delete', views.ActivityDeleteView.as_view(), name='delete_activity'),
|
||||
]
|
||||
|
@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
@ -153,6 +153,34 @@ class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
|
||||
|
||||
|
||||
class ActivityDeleteView(View):
|
||||
"""
|
||||
Deletes an Activity
|
||||
"""
|
||||
def delete(self, request, pk):
|
||||
try:
|
||||
activity = Activity.objects.get(pk=pk)
|
||||
activity.delete()
|
||||
return JsonResponse({"message": "Activity deleted"})
|
||||
except Activity.DoesNotExist:
|
||||
return JsonResponse({"error": "Activity not found"}, status=404)
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
"""
|
||||
Don't display the delete button if the user has no right to delete.
|
||||
"""
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
|
||||
activity = Activity.objects.get(pk=self.kwargs["pk"])
|
||||
if not PermissionBackend.check_perm(self.request, "activity.delete_activity", activity):
|
||||
raise PermissionDenied(_("You are not allowed to delete this activity."))
|
||||
|
||||
if activity.valid:
|
||||
raise PermissionDenied(_("This activity is valid."))
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
|
||||
class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`
|
||||
@ -168,6 +196,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
activity=activity,
|
||||
first_name="",
|
||||
last_name="",
|
||||
school="",
|
||||
inviter=self.request.user.note,
|
||||
)
|
||||
|
||||
|
@ -2,36 +2,58 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
from django.db import transaction
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
|
||||
from note_kfet.admin import admin_site
|
||||
|
||||
from .models import Allergen, BasicFood, QRCode, TransformedFood
|
||||
|
||||
|
||||
@admin.register(QRCode, site=admin_site)
|
||||
class QRCodeAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(BasicFood, site=admin_site)
|
||||
class BasicFoodAdmin(admin.ModelAdmin):
|
||||
@transaction.atomic
|
||||
def save_related(self, *args, **kwargs):
|
||||
ans = super().save_related(*args, **kwargs)
|
||||
args[1].instance.update()
|
||||
return ans
|
||||
|
||||
|
||||
@admin.register(TransformedFood, site=admin_site)
|
||||
class TransformedFoodAdmin(admin.ModelAdmin):
|
||||
exclude = ["allergens", "expiry_date"]
|
||||
|
||||
@transaction.atomic
|
||||
def save_related(self, request, form, *args, **kwargs):
|
||||
super().save_related(request, form, *args, **kwargs)
|
||||
form.instance.update()
|
||||
from .models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
@admin.register(Allergen, site=admin_site)
|
||||
class AllergenAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
"""
|
||||
Admin customisation for Allergen
|
||||
"""
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@admin.register(Food, site=admin_site)
|
||||
class FoodAdmin(PolymorphicParentModelAdmin):
|
||||
"""
|
||||
Admin customisation for Food
|
||||
"""
|
||||
child_models = (Food, BasicFood, TransformedFood)
|
||||
list_display = ('name', 'expiry_date', 'owner', 'is_ready')
|
||||
list_filter = ('is_ready', 'end_of_life')
|
||||
search_fields = ['name']
|
||||
ordering = ['expiry_date', 'name']
|
||||
|
||||
|
||||
@admin.register(BasicFood, site=admin_site)
|
||||
class BasicFood(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Admin customisation for BasicFood
|
||||
"""
|
||||
list_display = ('name', 'expiry_date', 'date_type', 'owner', 'is_ready')
|
||||
list_filter = ('is_ready', 'date_type', 'end_of_life')
|
||||
search_fields = ['name']
|
||||
ordering = ['expiry_date', 'name']
|
||||
|
||||
|
||||
@admin.register(TransformedFood, site=admin_site)
|
||||
class TransformedFood(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Admin customisation for TransformedFood
|
||||
"""
|
||||
list_display = ('name', 'expiry_date', 'shelf_life', 'owner', 'is_ready')
|
||||
list_filter = ('is_ready', 'end_of_life', 'shelf_life')
|
||||
search_fields = ['name']
|
||||
ordering = ['expiry_date', 'name']
|
||||
|
||||
|
||||
@admin.register(QRCode, site=admin_site)
|
||||
class QRCodeAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for QRCode
|
||||
"""
|
||||
list_diplay = ('qr_code_number', 'food_container')
|
||||
search_fields = ['food_container__name']
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
||||
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class AllergenSerializer(serializers.ModelSerializer):
|
||||
@ -11,40 +11,46 @@ class AllergenSerializer(serializers.ModelSerializer):
|
||||
REST API Serializer for Allergen.
|
||||
The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Allergen
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class FoodSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for Food.
|
||||
The djangorestframework plugin will analyse the model `Food` and parse all fields in the API.
|
||||
"""
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class BasicFoodSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for BasicFood.
|
||||
The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class QRCodeSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for QRCode.
|
||||
The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = QRCode
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class TransformedFoodSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for TransformedFood.
|
||||
The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class QRCodeSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for QRCode.
|
||||
The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
|
||||
"""
|
||||
class Meta:
|
||||
model = QRCode
|
||||
fields = '__all__'
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import AllergenViewSet, BasicFoodViewSet, QRCodeViewSet, TransformedFoodViewSet
|
||||
from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
|
||||
|
||||
|
||||
def register_food_urls(router, path):
|
||||
@ -9,6 +9,7 @@ def register_food_urls(router, path):
|
||||
Configure router for Food REST API.
|
||||
"""
|
||||
router.register(path + '/allergen', AllergenViewSet)
|
||||
router.register(path + '/basic_food', BasicFoodViewSet)
|
||||
router.register(path + '/food', FoodViewSet)
|
||||
router.register(path + '/basicfood', BasicFoodViewSet)
|
||||
router.register(path + '/transformedfood', TransformedFoodViewSet)
|
||||
router.register(path + '/qrcode', QRCodeViewSet)
|
||||
router.register(path + '/transformed_food', TransformedFoodViewSet)
|
||||
|
@ -5,8 +5,8 @@ from api.viewsets import ReadProtectedModelViewSet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter
|
||||
|
||||
from .serializers import AllergenSerializer, BasicFoodSerializer, QRCodeSerializer, TransformedFoodSerializer
|
||||
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
||||
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
|
||||
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class AllergenViewSet(ReadProtectedModelViewSet):
|
||||
@ -22,11 +22,24 @@ class AllergenViewSet(ReadProtectedModelViewSet):
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class FoodViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `Food` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/food/
|
||||
"""
|
||||
queryset = Food.objects.order_by('id')
|
||||
serializer_class = FoodSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class BasicFoodViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/basic_food/
|
||||
then render it on /api/food/basicfood/
|
||||
"""
|
||||
queryset = BasicFood.objects.order_by('id')
|
||||
serializer_class = BasicFoodSerializer
|
||||
@ -35,6 +48,19 @@ class BasicFoodViewSet(ReadProtectedModelViewSet):
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class TransformedFoodViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/transformedfood/
|
||||
"""
|
||||
queryset = TransformedFood.objects.order_by('id')
|
||||
serializer_class = TransformedFoodSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class QRCodeViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
@ -46,16 +72,3 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['qr_code_number', ]
|
||||
search_fields = ['$qr_code_number', ]
|
||||
|
||||
|
||||
class TransformedFoodViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/transformed_food/
|
||||
"""
|
||||
queryset = TransformedFood.objects.order_by('id')
|
||||
serializer_class = TransformedFoodSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', ]
|
||||
search_fields = ['$name', ]
|
||||
|
100
apps/food/fixtures/initial.json
Normal file
100
apps/food/fixtures/initial.json
Normal file
@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Lait"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Oeufs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Gluten"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Fruits à coques"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Arachides"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "Sésame"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Soja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"name": "Céleri"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"name": "Lupin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"name": "Moutarde"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 11,
|
||||
"fields": {
|
||||
"name": "Sulfites"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"name": "Crustacés"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 13,
|
||||
"fields": {
|
||||
"name": "Mollusques"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 14,
|
||||
"fields": {
|
||||
"name": "Poissons"
|
||||
}
|
||||
}
|
||||
]
|
@ -3,42 +3,41 @@
|
||||
|
||||
from random import shuffle
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from member.models import Club
|
||||
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||
from django import forms
|
||||
from django.forms.widgets import NumberInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club
|
||||
from note_kfet.inputs import Autocomplete
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models import BasicFood, QRCode, TransformedFood
|
||||
from .models import Food, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class AddIngredientForms(forms.ModelForm):
|
||||
class QRCodeForms(forms.ModelForm):
|
||||
"""
|
||||
Form for add an ingredient
|
||||
Form for create QRCode for container
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['ingredient'].queryset = self.fields['ingredient'].queryset.filter(
|
||||
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
|
||||
end_of_life__isnull=True,
|
||||
polymorphic_ctype__model='transformedfood',
|
||||
is_ready=False,
|
||||
is_active=True,
|
||||
was_eaten=False,
|
||||
)
|
||||
# Caution, the logic is inverted here, we flip the logic on saving in AddIngredientView
|
||||
self.fields['is_active'].initial = True
|
||||
self.fields['is_active'].label = _("Fully used")
|
||||
).filter(PermissionBackend.filter_queryset(
|
||||
get_current_request(),
|
||||
TransformedFood,
|
||||
"view",
|
||||
))
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = ('ingredient', 'is_active')
|
||||
model = QRCode
|
||||
fields = ('food_container',)
|
||||
|
||||
|
||||
class BasicFoodForms(forms.ModelForm):
|
||||
"""
|
||||
Form for add non-transformed food
|
||||
Form for add basicfood
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -51,64 +50,138 @@ class BasicFoodForms(forms.ModelForm):
|
||||
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||
shuffle(clubs)
|
||||
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||
self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs")
|
||||
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'is_active', 'was_eaten', 'allergens',)
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
'expiry_date': DateTimePickerInput(),
|
||||
"expiry_date": DateTimePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
class QRCodeForms(forms.ModelForm):
|
||||
"""
|
||||
Form for create QRCode
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
|
||||
is_active=True,
|
||||
was_eaten=False,
|
||||
polymorphic_ctype__model='transformedfood',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = QRCode
|
||||
fields = ('food_container',)
|
||||
|
||||
|
||||
class TransformedFoodForms(forms.ModelForm):
|
||||
"""
|
||||
Form for add transformed food
|
||||
Form for add transformedfood
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
|
||||
self.fields['name'].required = True
|
||||
self.fields['owner'].required = True
|
||||
self.fields['creation_date'].required = True
|
||||
self.fields['creation_date'].initial = timezone.now
|
||||
self.fields['is_active'].initial = True
|
||||
self.fields['is_ready'].initial = False
|
||||
self.fields['was_eaten'].initial = False
|
||||
|
||||
# Some example
|
||||
self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
|
||||
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||
shuffle(clubs)
|
||||
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||
self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs")
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = ('name', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life')
|
||||
fields = ('name', 'owner', 'order',)
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
'creation_date': DateTimePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
class BasicFoodUpdateForms(forms.ModelForm):
|
||||
"""
|
||||
Form for update basicfood object
|
||||
"""
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens')
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
"expiry_date": DateTimePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
class TransformedFoodUpdateForms(forms.ModelForm):
|
||||
"""
|
||||
Form for update transformedfood object
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['shelf_life'].label = _('Shelf life (in hours)')
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = ('name', 'owner', 'end_of_life', 'is_ready', 'order', 'shelf_life')
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
"expiry_date": DateTimePickerInput(),
|
||||
"shelf_life": NumberInput(),
|
||||
}
|
||||
|
||||
|
||||
class AddIngredientForms(forms.ModelForm):
|
||||
"""
|
||||
Form for add an ingredient
|
||||
"""
|
||||
fully_used = forms.BooleanField()
|
||||
fully_used.initial = True
|
||||
fully_used.required = False
|
||||
fully_used.label = _("Fully used")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# TODO find a better way to get pk (be not url scheme dependant)
|
||||
pk = get_current_request().path.split('/')[-1]
|
||||
self.fields['ingredients'].queryset = self.fields['ingredients'].queryset.filter(
|
||||
polymorphic_ctype__model="transformedfood",
|
||||
is_ready=False,
|
||||
end_of_life='',
|
||||
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")).exclude(pk=pk)
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = ('ingredients',)
|
||||
|
||||
|
||||
class ManageIngredientsForm(forms.Form):
|
||||
"""
|
||||
Form to manage ingredient
|
||||
"""
|
||||
fully_used = forms.BooleanField()
|
||||
fully_used.initial = True
|
||||
fully_used.required = True
|
||||
fully_used.label = _('Fully used')
|
||||
|
||||
name = forms.CharField()
|
||||
name.widget = Autocomplete(
|
||||
model=Food,
|
||||
resetable=True,
|
||||
attrs={"api_url": "/api/food/food",
|
||||
"class": "autocomplete"},
|
||||
)
|
||||
name.label = _('Name')
|
||||
|
||||
qrcode = forms.IntegerField()
|
||||
qrcode.widget = Autocomplete(
|
||||
model=QRCode,
|
||||
resetable=True,
|
||||
attrs={"api_url": "/api/food/qrcode/",
|
||||
"name_field": "qr_code_number",
|
||||
"class": "autocomplete"},
|
||||
)
|
||||
qrcode.label = _('QR code number')
|
||||
|
||||
|
||||
ManageIngredientsFormSet = forms.formset_factory(
|
||||
ManageIngredientsForm,
|
||||
extra=1,
|
||||
)
|
||||
|
@ -1,84 +1,199 @@
|
||||
# Generated by Django 2.2.28 on 2024-07-05 08:57
|
||||
# Generated by Django 4.2.20 on 2025-04-17 21:43
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('member', '0011_profile_vss_charter_read'),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("member", "0013_auto_20240801_1436"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Allergen',
|
||||
name="Allergen",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="name")),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Allergen',
|
||||
'verbose_name_plural': 'Allergens',
|
||||
"verbose_name": "Allergen",
|
||||
"verbose_name_plural": "Allergens",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Food',
|
||||
name="Food",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('expiry_date', models.DateTimeField(verbose_name='expiry date')),
|
||||
('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')),
|
||||
('is_ready', models.BooleanField(default=False, verbose_name='is ready')),
|
||||
('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='owner')),
|
||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_food.food_set+', to='contenttypes.ContentType')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="name")),
|
||||
("expiry_date", models.DateTimeField(verbose_name="expiry date")),
|
||||
(
|
||||
"end_of_life",
|
||||
models.CharField(max_length=255, verbose_name="end of life"),
|
||||
),
|
||||
(
|
||||
"is_ready",
|
||||
models.BooleanField(max_length=255, verbose_name="is ready"),
|
||||
),
|
||||
("order", models.CharField(max_length=255, verbose_name="order")),
|
||||
(
|
||||
"allergens",
|
||||
models.ManyToManyField(
|
||||
blank=True, to="food.allergen", verbose_name="allergens"
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="+",
|
||||
to="member.club",
|
||||
verbose_name="owner",
|
||||
),
|
||||
),
|
||||
(
|
||||
"polymorphic_ctype",
|
||||
models.ForeignKey(
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="polymorphic_%(app_label)s.%(class)s_set+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'foods',
|
||||
"verbose_name": "Food",
|
||||
"verbose_name_plural": "Foods",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BasicFood',
|
||||
name="BasicFood",
|
||||
fields=[
|
||||
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
|
||||
('date_type', models.CharField(choices=[('DLC', 'DLC'), ('DDM', 'DDM')], max_length=255)),
|
||||
('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')),
|
||||
(
|
||||
"food_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="food.food",
|
||||
),
|
||||
),
|
||||
(
|
||||
"arrival_date",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="arrival date"
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_type",
|
||||
models.CharField(
|
||||
choices=[("DLC", "DLC"), ("DDM", "DDM")], max_length=255
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Basic food',
|
||||
'verbose_name_plural': 'Basic foods',
|
||||
"verbose_name": "Basic food",
|
||||
"verbose_name_plural": "Basic foods",
|
||||
},
|
||||
bases=('food.food',),
|
||||
bases=("food.food",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QRCode',
|
||||
name="QRCode",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('qr_code_number', models.PositiveIntegerField(unique=True, verbose_name='QR-code number')),
|
||||
('food_container', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='QR_code', to='food.Food', verbose_name='food container')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"qr_code_number",
|
||||
models.PositiveIntegerField(
|
||||
unique=True, verbose_name="qr code number"
|
||||
),
|
||||
),
|
||||
(
|
||||
"food_container",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="QR_code",
|
||||
to="food.food",
|
||||
verbose_name="food container",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'QR-code',
|
||||
'verbose_name_plural': 'QR-codes',
|
||||
"verbose_name": "QR-code",
|
||||
"verbose_name_plural": "QR-codes",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TransformedFood',
|
||||
name="TransformedFood",
|
||||
fields=[
|
||||
('food_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='food.Food')),
|
||||
('creation_date', models.DateTimeField(verbose_name='creation date')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='is active')),
|
||||
('ingredient', models.ManyToManyField(blank=True, related_name='transformed_ingredient_inv', to='food.Food', verbose_name='transformed ingredient')),
|
||||
(
|
||||
"food_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="food.food",
|
||||
),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="creation date"
|
||||
),
|
||||
),
|
||||
(
|
||||
"shelf_life",
|
||||
models.DurationField(
|
||||
default=datetime.timedelta(days=3), verbose_name="shelf life"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ingredients",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="transformed_ingredient_inv",
|
||||
to="food.food",
|
||||
verbose_name="transformed ingredient",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Transformed food',
|
||||
'verbose_name_plural': 'Transformed foods',
|
||||
"verbose_name": "Transformed food",
|
||||
"verbose_name_plural": "Transformed foods",
|
||||
},
|
||||
bases=('food.food',),
|
||||
bases=("food.food",),
|
||||
),
|
||||
]
|
||||
|
@ -1,19 +0,0 @@
|
||||
# Generated by Django 2.2.28 on 2024-07-06 20:37
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('food', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transformedfood',
|
||||
name='shelf_life',
|
||||
field=models.DurationField(default=datetime.timedelta(days=3), verbose_name='shelf life'),
|
||||
),
|
||||
]
|
@ -1,62 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
def create_14_mandatory_allergens(apps, schema_editor):
|
||||
"""
|
||||
There are 14 mandatory allergens, they are pre-injected
|
||||
"""
|
||||
|
||||
Allergen = apps.get_model("food", "allergen")
|
||||
|
||||
Allergen.objects.get_or_create(
|
||||
name="Gluten",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Fruits à coques",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Crustacés",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Céléri",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Oeufs",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Moutarde",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Poissons",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Soja",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Lait",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Sulfites",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Sésame",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Lupin",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Arachides",
|
||||
)
|
||||
Allergen.objects.get_or_create(
|
||||
name="Mollusques",
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('food', '0002_transformedfood_shelf_life'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_14_mandatory_allergens),
|
||||
]
|
||||
|
||||
|
@ -1,28 +0,0 @@
|
||||
# Generated by Django 2.2.28 on 2024-08-13 21:58
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('food', '0003_create_14_allergens_mandatory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='transformedfood',
|
||||
name='is_active',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, verbose_name='is active'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='qrcode',
|
||||
name='food_container',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='QR_code', to='food.Food', verbose_name='food container'),
|
||||
),
|
||||
]
|
@ -1,20 +0,0 @@
|
||||
# Generated by Django 4.2.15 on 2024-08-28 08:00
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('food', '0004_auto_20240813_2358'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='food',
|
||||
name='polymorphic_ctype',
|
||||
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
|
||||
),
|
||||
]
|
@ -6,37 +6,13 @@ from datetime import timedelta
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club
|
||||
from polymorphic.models import PolymorphicModel
|
||||
|
||||
|
||||
class QRCode(models.Model):
|
||||
"""
|
||||
An QRCode model
|
||||
"""
|
||||
qr_code_number = models.PositiveIntegerField(
|
||||
verbose_name=_("QR-code number"),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
food_container = models.ForeignKey(
|
||||
'Food',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='QR_code',
|
||||
verbose_name=_('food container'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("QR-code")
|
||||
verbose_name_plural = _("QR-codes")
|
||||
|
||||
def __str__(self):
|
||||
return _("QR-code number {qr_code_number}").format(qr_code_number=self.qr_code_number)
|
||||
from member.models import Club
|
||||
|
||||
|
||||
class Allergen(models.Model):
|
||||
"""
|
||||
A list of allergen and alimentary restrictions
|
||||
Allergen and alimentary restrictions
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
@ -44,16 +20,19 @@ class Allergen(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Allergen')
|
||||
verbose_name_plural = _('Allergens')
|
||||
verbose_name = _("Allergen")
|
||||
verbose_name_plural = _("Allergens")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Food(PolymorphicModel):
|
||||
"""
|
||||
Describe any type of food
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
verbose_name=_("name"),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
@ -67,7 +46,7 @@ class Food(PolymorphicModel):
|
||||
allergens = models.ManyToManyField(
|
||||
Allergen,
|
||||
blank=True,
|
||||
verbose_name=_('allergen'),
|
||||
verbose_name=_('allergens'),
|
||||
)
|
||||
|
||||
expiry_date = models.DateTimeField(
|
||||
@ -75,41 +54,69 @@ class Food(PolymorphicModel):
|
||||
null=False,
|
||||
)
|
||||
|
||||
was_eaten = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('was eaten'),
|
||||
end_of_life = models.CharField(
|
||||
blank=True,
|
||||
verbose_name=_('end of life'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
# is_ready != is_active : is_ready signifie que la nourriture est prête à être manger,
|
||||
# is_active signifie que la nourriture n'est pas encore archivé
|
||||
# il sert dans les cas où il est plus intéressant que de l'open soit conservé (confiture par ex)
|
||||
|
||||
is_ready = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('is ready'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('is active'),
|
||||
order = models.CharField(
|
||||
blank=True,
|
||||
verbose_name=_('order'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
def update_allergens(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
old_allergens = list(parent.allergens.all()).copy()
|
||||
parent.allergens.clear()
|
||||
for child in parent.ingredients.iterator():
|
||||
if child.pk != self.pk:
|
||||
parent.allergens.set(parent.allergens.union(child.allergens.all()))
|
||||
parent.allergens.set(parent.allergens.union(self.allergens.all()))
|
||||
if old_allergens != list(parent.allergens.all()):
|
||||
parent.save(old_allergens=old_allergens)
|
||||
|
||||
def update_expiry_date(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
old_expiry_date = parent.expiry_date
|
||||
parent.expiry_date = parent.shelf_life + parent.creation_date
|
||||
for child in parent.ingredients.iterator():
|
||||
if (child.pk != self.pk
|
||||
and not (child.polymorphic_ctype.model == 'basicfood'
|
||||
and child.date_type == 'DDM')):
|
||||
parent.expiry_date = min(parent.expiry_date, child.expiry_date)
|
||||
|
||||
if self.polymorphic_ctype.model == 'basicfood' and self.date_type == 'DLC':
|
||||
parent.expiry_date = min(parent.expiry_date, self.expiry_date)
|
||||
if old_expiry_date != parent.expiry_date:
|
||||
parent.save()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('food')
|
||||
verbose_name = _('foods')
|
||||
verbose_name = _('Food')
|
||||
verbose_name_plural = _('Foods')
|
||||
|
||||
|
||||
class BasicFood(Food):
|
||||
"""
|
||||
Food which has been directly buy on supermarket
|
||||
A basic food is a food directly buy and stored
|
||||
"""
|
||||
arrival_date = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_('arrival date'),
|
||||
)
|
||||
|
||||
date_type = models.CharField(
|
||||
max_length=255,
|
||||
choices=(
|
||||
@ -118,50 +125,70 @@ class BasicFood(Food):
|
||||
)
|
||||
)
|
||||
|
||||
arrival_date = models.DateTimeField(
|
||||
verbose_name=_('arrival date'),
|
||||
default=timezone.now,
|
||||
)
|
||||
|
||||
# label = models.ImageField(
|
||||
# verbose_name=_('food label'),
|
||||
# max_length=255,
|
||||
# blank=False,
|
||||
# null=False,
|
||||
# upload_to='label/',
|
||||
# )
|
||||
|
||||
@transaction.atomic
|
||||
def update_allergens(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
parent.update_allergens()
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
|
||||
created = self.pk is None
|
||||
if not created:
|
||||
# Check if important fields are updated
|
||||
old_food = Food.objects.select_for_update().get(pk=self.pk)
|
||||
if not hasattr(self, "_force_save"):
|
||||
# Allergens
|
||||
|
||||
@transaction.atomic
|
||||
def update_expiry_date(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
parent.update_expiry_date()
|
||||
if ('old_allergens' in kwargs
|
||||
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||
self.update_allergens()
|
||||
|
||||
@transaction.atomic
|
||||
def update(self):
|
||||
self.update_allergens()
|
||||
self.update_expiry_date()
|
||||
# Expiry date
|
||||
if ((self.expiry_date != old_food.expiry_date
|
||||
and self.date_type == 'DLC')
|
||||
or old_food.date_type != self.date_type):
|
||||
self.update_expiry_date()
|
||||
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
@staticmethod
|
||||
def get_lastests_objects(number, distinct_field, order_by_field):
|
||||
"""
|
||||
Get the last object with distinct field and ranked with order_by
|
||||
This methods exist because we can't distinct with one field and
|
||||
order with another
|
||||
"""
|
||||
foods = BasicFood.objects.order_by(order_by_field).all()
|
||||
field = []
|
||||
for food in foods:
|
||||
if getattr(food, distinct_field) in field:
|
||||
continue
|
||||
else:
|
||||
field.append(getattr(food, distinct_field))
|
||||
number -= 1
|
||||
yield food
|
||||
if not number:
|
||||
return
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Basic food')
|
||||
verbose_name_plural = _('Basic foods')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class TransformedFood(Food):
|
||||
"""
|
||||
Transformed food are a mix between basic food and meal
|
||||
A transformed food is a food with ingredients
|
||||
"""
|
||||
creation_date = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_('creation date'),
|
||||
)
|
||||
|
||||
ingredient = models.ManyToManyField(
|
||||
# Without microbiological analyzes, the storage time is 3 days
|
||||
shelf_life = models.DurationField(
|
||||
default=timedelta(days=3),
|
||||
verbose_name=_('shelf life'),
|
||||
)
|
||||
|
||||
ingredients = models.ManyToManyField(
|
||||
Food,
|
||||
blank=True,
|
||||
symmetrical=False,
|
||||
@ -169,58 +196,91 @@ class TransformedFood(Food):
|
||||
verbose_name=_('transformed ingredient'),
|
||||
)
|
||||
|
||||
# Without microbiological analyzes, the storage time is 3 days
|
||||
shelf_life = models.DurationField(
|
||||
verbose_name=_("shelf life"),
|
||||
default=timedelta(days=3),
|
||||
)
|
||||
def check_cycle(self, ingredients, origin, checked):
|
||||
for ingredient in ingredients:
|
||||
if ingredient == origin:
|
||||
# We break the cycle
|
||||
self.ingredients.remove(ingredient)
|
||||
if ingredient.polymorphic_ctype.model == 'transformedfood' and ingredient not in checked:
|
||||
ingredient.check_cycle(ingredient.ingredients.all(), origin, checked)
|
||||
checked.append(ingredient)
|
||||
|
||||
@transaction.atomic
|
||||
def archive(self):
|
||||
# When a meal are archived, if it was eaten, update ingredient fully used for this meal
|
||||
raise NotImplementedError
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
|
||||
created = self.pk is None
|
||||
if not created:
|
||||
# Check if important fields are updated
|
||||
update = {'allergens': False, 'expiry_date': False}
|
||||
old_food = Food.objects.select_for_update().get(pk=self.pk)
|
||||
if not hasattr(self, "_force_save"):
|
||||
# Allergens
|
||||
# Unfortunately with the many-to-many relation we can't access
|
||||
# to old allergens
|
||||
if ('old_allergens' in kwargs
|
||||
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||
update['allergens'] = True
|
||||
|
||||
@transaction.atomic
|
||||
def update_allergens(self):
|
||||
# When allergens are changed, simply update the parents' allergens
|
||||
old_allergens = list(self.allergens.all())
|
||||
self.allergens.clear()
|
||||
for ingredient in self.ingredient.iterator():
|
||||
self.allergens.set(self.allergens.union(ingredient.allergens.all()))
|
||||
# Expiry date
|
||||
update['expiry_date'] = (self.shelf_life != old_food.shelf_life
|
||||
or self.creation_date != old_food.creation_date)
|
||||
if update['expiry_date']:
|
||||
self.expiry_date = self.creation_date + self.shelf_life
|
||||
# Unfortunately with the set method ingredients are already save,
|
||||
# we check cycle after if possible
|
||||
if ('old_ingredients' in kwargs
|
||||
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
|
||||
update['allergens'] = True
|
||||
update['expiry_date'] = True
|
||||
|
||||
if old_allergens == list(self.allergens.all()):
|
||||
return
|
||||
super().save()
|
||||
# it's preferable to keep a queryset but we allow list too
|
||||
if type(kwargs['old_ingredients']) is list:
|
||||
kwargs['old_ingredients'] = Food.objects.filter(
|
||||
pk__in=[food.pk for food in kwargs['old_ingredients']])
|
||||
self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
|
||||
if update['allergens']:
|
||||
self.update_allergens()
|
||||
if update['expiry_date']:
|
||||
self.update_expiry_date()
|
||||
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
parent.update_allergens()
|
||||
if created:
|
||||
self.expiry_date = self.shelf_life + self.creation_date
|
||||
|
||||
@transaction.atomic
|
||||
def update_expiry_date(self):
|
||||
# When expiry_date is changed, simply update the parents' expiry_date
|
||||
old_expiry_date = self.expiry_date
|
||||
self.expiry_date = self.creation_date + self.shelf_life
|
||||
for ingredient in self.ingredient.iterator():
|
||||
self.expiry_date = min(self.expiry_date, ingredient.expiry_date)
|
||||
# We save here because we need pk for many-to-many relation
|
||||
super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
if old_expiry_date == self.expiry_date:
|
||||
return
|
||||
super().save()
|
||||
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
parent.update_expiry_date()
|
||||
|
||||
@transaction.atomic
|
||||
def update(self):
|
||||
self.update_allergens()
|
||||
self.update_expiry_date()
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
for child in self.ingredients.iterator():
|
||||
self.allergens.set(self.allergens.union(child.allergens.all()))
|
||||
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
|
||||
self.expiry_date = min(self.expiry_date, child.expiry_date)
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Transformed food')
|
||||
verbose_name_plural = _('Transformed foods')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class QRCode(models.Model):
|
||||
"""
|
||||
QR-code for register food
|
||||
"""
|
||||
qr_code_number = models.PositiveIntegerField(
|
||||
unique=True,
|
||||
verbose_name=_('qr code number'),
|
||||
)
|
||||
|
||||
food_container = models.ForeignKey(
|
||||
Food,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='QR_code',
|
||||
verbose_name=_('food container'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('QR-code')
|
||||
verbose_name_plural = _('QR-codes')
|
||||
|
||||
def __str__(self):
|
||||
return _('QR-code number') + ' ' + str(self.qr_code_number)
|
||||
|
@ -2,18 +2,20 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import django_tables2 as tables
|
||||
from django_tables2 import A
|
||||
|
||||
from .models import TransformedFood
|
||||
from .models import Food
|
||||
|
||||
|
||||
class TransformedFoodTable(tables.Table):
|
||||
name = tables.LinkColumn(
|
||||
'food:food_view',
|
||||
args=[A('pk'), ],
|
||||
)
|
||||
|
||||
class FoodTable(tables.Table):
|
||||
"""
|
||||
List all foods.
|
||||
"""
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
model = Food
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('name', "owner", "allergens", "expiry_date")
|
||||
fields = ('name', 'owner', 'allergens', 'expiry_date')
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'data-href': lambda record: 'detail/' + str(record.pk),
|
||||
'style': 'cursor:pointer',
|
||||
}
|
||||
|
@ -1,20 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,37 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }} {{ food.name }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
|
||||
<li><p>{% trans 'Arrival date' %} : {{ food.arrival_date }}</p></li>
|
||||
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }} ({{ food.date_type }})</p></li>
|
||||
<li>{% trans 'Allergens' %} :</li>
|
||||
<ul>
|
||||
{% for allergen in food.allergens.iterator %}
|
||||
<li>{{ allergen.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p>
|
||||
<li><p>{% trans 'Active' %} : {{ food.is_active }}<p></li>
|
||||
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}<p></li>
|
||||
</ul>
|
||||
{% if can_update %}
|
||||
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=food.pk %}">{% trans 'Update' %}</a>
|
||||
{% endif %}
|
||||
{% if can_add_ingredient %}
|
||||
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||
{% trans 'Add to a meal' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,55 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<a class="btn btn-sm btn-success" href="{% url "food:qrcode_basic_create" slug=slug %}">
|
||||
{% trans 'New basic food' %}
|
||||
</a>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
<div class="card-body" id="profile_infos">
|
||||
<h4>{% trans "Copy constructor" %}</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="orderable">
|
||||
{% trans "Name" %}
|
||||
</th>
|
||||
<th class="orderable">
|
||||
{% trans "Owner" %}
|
||||
</th>
|
||||
<th class="orderable">
|
||||
{% trans "Arrival date" %}
|
||||
</th>
|
||||
<th class="orderable">
|
||||
{% trans "Expiry date" %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for basic in last_basic %}
|
||||
<tr>
|
||||
<td><a href="{% url "food:qrcode_basic_create" slug=slug %}?copy={{ basic.pk }}">{{ basic.name }}</a></td>
|
||||
<td>{{ basic.owner }}</td>
|
||||
<td>{{ basic.arrival_date }}</td>
|
||||
<td>{{ basic.expiry_date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
56
apps/food/templates/food/food_detail.html
Normal file
56
apps/food/templates/food/food_detail.html
Normal file
@ -0,0 +1,56 @@
|
||||
{% 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>
|
||||
{% if QR_code %}
|
||||
<li> {{QR_code}} </li>
|
||||
{% endif %}
|
||||
{% 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 %}
|
132
apps/food/templates/food/food_list.html
Normal file
132
apps/food/templates/food/food_list.html
Normal file
@ -0,0 +1,132 @@
|
||||
{% 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 %}
|
||||
<div class="card bg-light">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<style>
|
||||
input[type=number]::-webkit-inner-spin-button,
|
||||
input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type=number] {
|
||||
appearance: textfield;
|
||||
padding: 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
<div class="d-flex align-items-center" style="max-width: 300px;">
|
||||
<form method="get" action="{% url 'food:redirect_view' %}" class="d-flex w-100">
|
||||
<input type="number" name="slug" placeholder="QR-code" required class="form-control form-control-sm" style="max-width: 120px;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans "View food" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input id="searchbar" type="text" class="form-control"
|
||||
placeholder="{% trans "Search by attribute such as name..." %}">
|
||||
</div>
|
||||
|
||||
{% block extra_inside_card %}
|
||||
{% endblock %}
|
||||
|
||||
<div id="dynamic-table">
|
||||
{% if table.data %}
|
||||
{% render_table table %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no results." %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('goButton').addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
const slug = document.getElementById('slugInput').value;
|
||||
if (slug && !isNaN(slug)) {
|
||||
window.location.href = `/food/${slug}/`;
|
||||
} else {
|
||||
alert("Veuillez entrer un nombre valide.");
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
116
apps/food/templates/food/manage_ingredients.html
Normal file
116
apps/food/templates/food/manage_ingredients.html
Normal file
@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form"></div>
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<table class="table table-condensed table-striped">
|
||||
{# Fill initial data #}
|
||||
{% for display, form in formset %}
|
||||
{% if forloop.first %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ form.name.label }}</th>
|
||||
<th>{{ form.qrcode.label }}</th>
|
||||
<th>{{ form.fully_used.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="form_body">
|
||||
{% endif %}
|
||||
{% if display %}
|
||||
<tr class="row-formset ingredients">
|
||||
{% else %}
|
||||
<tr class="row-formset ingredients" style="display: none">
|
||||
{% endif %}
|
||||
<td>{{ form.name }}</td>
|
||||
<td>{{ form.qrcode }}</td>
|
||||
<td>{{ form.fully_used }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# Display buttons to add and remove ingredients #}
|
||||
<div class="card-body">
|
||||
<div class="btn-group btn-block" role="group">
|
||||
<button type="button" id="add_more" class="btn btn-success">{% trans "Add ingredient" %}</button>
|
||||
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove ingredient" %}</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
/* script that handles add and remove lines */
|
||||
|
||||
const foods = {{ ingredients | safe }};
|
||||
|
||||
function set_ingredient_id () {
|
||||
let ingredients = document.getElementsByClassName('ingredients');
|
||||
for (var i = 0; i < ingredients.length; i++) {
|
||||
ingredients[i].id = 'ingredients-' + parseInt(i);
|
||||
};
|
||||
}
|
||||
set_ingredient_id();
|
||||
|
||||
function prepopulate () {
|
||||
for (var i = 0; i < {{ ingredients_count }}; i++) {
|
||||
let prefix = 'id_form-' + parseInt(i) + '-';
|
||||
document.getElementById(prefix + 'name_pk').value = parseInt(foods[i]['food_pk']);
|
||||
document.getElementById(prefix + 'name').value = foods[i]['food_name'];
|
||||
document.getElementById(prefix + 'qrcode_pk').value = parseInt(foods[i]['qr_pk']);
|
||||
if (foods[i]['qr_number'] === '') {
|
||||
document.getElementById(prefix + 'qrcode').value = '';
|
||||
}
|
||||
else {
|
||||
document.getElementById(prefix + 'qrcode').value = parseInt(foods[i]['qr_number']);
|
||||
};
|
||||
document.getElementById(prefix + 'fully_used').checked = Boolean(foods[i]['fully_used']);
|
||||
};
|
||||
}
|
||||
prepopulate();
|
||||
|
||||
function delete_form_data (form_id) {
|
||||
let prefix = "id_form-" + parseInt(form_id) + "-";
|
||||
document.getElementById(prefix + "name_pk").value = "";
|
||||
document.getElementById(prefix + "name").value = "";
|
||||
document.getElementById(prefix + "qrcode_pk").value = "";
|
||||
document.getElementById(prefix + "qrcode").value = "";
|
||||
document.getElementById(prefix + "fully_used").checked = true;
|
||||
}
|
||||
var form_count = {{ ingredients_count }} + 1;
|
||||
|
||||
$('#add_more').click(function () {
|
||||
let ingredient_form = document.getElementById('ingredients-' + parseInt(form_count));
|
||||
if (ingredient_form === null) {
|
||||
addMsg(gettext("You can't add more ingredient"), "danger", 5000);
|
||||
return;};
|
||||
ingredient_form.style = "display: true";
|
||||
form_count += 1;
|
||||
});
|
||||
|
||||
$('#remove_one').click(function () {
|
||||
let ingredient_form = document.getElementById('ingredients-' + parseInt(form_count - 1));
|
||||
if (ingredient_form === null) {
|
||||
return;};
|
||||
ingredient_form.style = "display: none";
|
||||
delete_form_data(form_count - 1);
|
||||
form_count -= 1;
|
||||
});
|
||||
|
||||
addMsg(gettext("Add ingredient with their name or their qrcode, if two different priority is given to qrcode"), "warning");
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
52
apps/food/templates/food/qrcode.html
Normal file
52
apps/food/templates/food/qrcode.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||
</form>
|
||||
<div class="card-body">
|
||||
<h4>
|
||||
{% trans "Copy constructor" %}
|
||||
<a class="btn btn-secondary" href="{% url "food:basicfood_create" slug=slug %}">{% trans "New food" %}</a>
|
||||
</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="orderable">
|
||||
{% trans "Name" %}
|
||||
</th>
|
||||
<th class="orderable">
|
||||
{% trans "Owner" %}
|
||||
</th>
|
||||
<th class="orderable">
|
||||
{% trans "Expiry date" %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for food in last_items %}
|
||||
<tr>
|
||||
<td><a href="{% url "food:basicfood_create" slug=slug %}?copy={{ food.pk }}">{{ food.name }}</a></td>
|
||||
<td>{{ food.owner }}</td>
|
||||
<td>{{ food.expiry_date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,39 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }} {% trans 'number' %} {{ qrcode.qr_code_number }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li><p>{% trans 'Name' %} : {{ qrcode.food_container.name }}</p></li>
|
||||
<li><p>{% trans 'Owner' %} : {{ qrcode.food_container.owner }}</p></li>
|
||||
<li><p>{% trans 'Expiry date' %} : {{ qrcode.food_container.expiry_date }}</p></li>
|
||||
</ul>
|
||||
{% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %}
|
||||
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=qrcode.food_container.pk %}" data-turbolinks="false">
|
||||
{% trans 'Update' %}
|
||||
</a>
|
||||
{% elif can_update_transformed %}
|
||||
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=qrcode.food_container.pk %}">
|
||||
{% trans 'Update' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_view_detail %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url "food:food_view" pk=qrcode.food_container.pk %}">
|
||||
{% trans 'View details' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_add_ingredient %}
|
||||
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=qrcode.food_container.pk %}">
|
||||
{% trans 'Add to a meal' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,51 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }} {{ food.name }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
|
||||
{% if can_see_ready %}
|
||||
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
|
||||
{% endif %}
|
||||
<li><p>{% trans 'Creation date' %} : {{ food.creation_date }}</p></li>
|
||||
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }}</p></li>
|
||||
<li>{% trans 'Allergens' %} :</li>
|
||||
<ul>
|
||||
{% for allergen in food.allergens.iterator %}
|
||||
<li>{{ allergen.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p>
|
||||
<li>{% trans 'Ingredients' %} :</li>
|
||||
<ul>
|
||||
{% for ingredient in food.ingredient.iterator %}
|
||||
<li><a href="{% url "food:food_view" pk=ingredient.pk %}">{{ ingredient.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p>
|
||||
<li><p>{% trans 'Shelf life' %} : {{ food.shelf_life }}</p></li>
|
||||
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
|
||||
<li><p>{% trans 'Active' %} : {{ food.is_active }}</p></li>
|
||||
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}</p></li>
|
||||
</ul>
|
||||
{% if can_update %}
|
||||
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=food.pk %}">
|
||||
{% trans 'Update' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_add_ingredient %}
|
||||
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||
{% trans 'Add to a meal' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,20 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,60 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Meal served" %}
|
||||
</h3>
|
||||
{% if can_create_meal %}
|
||||
<div class="card-footer">
|
||||
<a class="btn btn-sm btn-success" href="{% url 'food:transformed_create' %}" data-turbolinks="false">
|
||||
{% trans 'New meal' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if served.data %}
|
||||
{% render_table served %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no meal served." %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Open" %}
|
||||
</h3>
|
||||
{% if open.data %}
|
||||
{% render_table open %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no free meal." %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "All meals" %}
|
||||
</h3>
|
||||
{% if table.data %}
|
||||
{% render_table table %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no meal." %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
87
apps/food/templates/food/transformedfood_update.html
Normal file
87
apps/food/templates/food/transformedfood_update.html
Normal file
@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
<table class="table table-condensed table-striped">
|
||||
{# Fill initial data #}
|
||||
{% for ingredient_form in formset %}
|
||||
{% if forloop.first %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "QR-code number" %}</th>
|
||||
<th>{% trans "Fully used" %}<th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="form_body">
|
||||
{% endif %}
|
||||
<tr class="row-formset">
|
||||
{{ ingredient_form | crispy }}
|
||||
<td>{{ ingredient_form.name }}</td>
|
||||
<td>{{ ingredient_form.qrcode }}</td>
|
||||
<td>{{ ingredient_form.fully_used }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{# Display buttons to add and remove products #}
|
||||
<div class="card-body">
|
||||
<div class="btn-group btn-block" role="group">
|
||||
<button type="button" id="add_more" class="btn btn-success">{% trans "Add ingredient" %}</button>
|
||||
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove ingredient" %}</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-block btn-primary">{% trans "Submit" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hidden div that store an empty product form, to be copied into new forms #}
|
||||
<div id="empty_form" style="display: none;">
|
||||
<table class='no_error'>
|
||||
<tbody id="for_real">
|
||||
<tr class="row-formset">
|
||||
<td>{{ formset.empty_form.name }}</td>
|
||||
<td>{{ formset.empty_form.qrcode }}</td>
|
||||
<td>{{ formset.empty_form.fully_used }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
/* script that handles add and remove lines */
|
||||
IDS = {};
|
||||
|
||||
$("#id_form-TOTAL_FORMS").val($(".row-formset").length - 1);
|
||||
|
||||
$('#add_more').click(function () {
|
||||
let form_idx = $('#id_form-TOTAL_FORMS').val();
|
||||
$('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx));
|
||||
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
|
||||
$('#id_form-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]);
|
||||
});
|
||||
|
||||
$('#remove_one').click(function () {
|
||||
let form_idx = $('#id_form-TOTAL_FORMS').val();
|
||||
if (form_idx > 0) {
|
||||
IDS[parseInt(form_idx) - 1] = $('#id_form-' + (parseInt(form_idx) - 1) + '-id').val();
|
||||
$('#form_body tr:last-child').remove();
|
||||
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) - 1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,3 +0,0 @@
|
||||
# from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
170
apps/food/tests/test_food.py
Normal file
170
apps/food/tests/test_food.py
Normal file
@ -0,0 +1,170 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from api.tests import TestAPI
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
|
||||
from ..models import Allergen, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class TestFood(TestCase):
|
||||
"""
|
||||
Test food
|
||||
"""
|
||||
fixtures = ('initial',)
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_superuser(
|
||||
username='admintoto',
|
||||
password='toto1234',
|
||||
email='toto@example.com'
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
sess = self.client.session
|
||||
sess['permission_mask'] = 42
|
||||
sess.save()
|
||||
|
||||
self.allergen = Allergen.objects.create(
|
||||
name='allergen',
|
||||
)
|
||||
|
||||
self.basicfood = BasicFood.objects.create(
|
||||
name='basicfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
date_type='DLC',
|
||||
)
|
||||
|
||||
self.transformedfood = TransformedFood.objects.create(
|
||||
name='transformedfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
)
|
||||
|
||||
self.qrcode = QRCode.objects.create(
|
||||
qr_code_number=1,
|
||||
food_container=self.basicfood,
|
||||
)
|
||||
|
||||
def test_food_list(self):
|
||||
"""
|
||||
Display food list
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_qrcode_create(self):
|
||||
"""
|
||||
Display QRCode creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:qrcode_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_basicfood_create(self):
|
||||
"""
|
||||
Display BasicFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_transformedfood_create(self):
|
||||
"""
|
||||
Display TransformedFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_food_create(self):
|
||||
"""
|
||||
Display Food update
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_update'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_food_view(self):
|
||||
"""
|
||||
Display Food detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_view'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_basicfood_view(self):
|
||||
"""
|
||||
Display BasicFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_view'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_transformedfood_view(self):
|
||||
"""
|
||||
Display TransformedFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_view'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_ingredient(self):
|
||||
"""
|
||||
Display add ingredient view
|
||||
"""
|
||||
response = self.client.get(reverse('food:add_ingredient'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class TestFoodAPI(TestAPI):
|
||||
def setUp(self) -> None:
|
||||
super().setUP()
|
||||
|
||||
self.allergen = Allergen.objects.create(
|
||||
name='name',
|
||||
)
|
||||
|
||||
self.basicfood = BasicFood.objects.create(
|
||||
name='basicfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
date_type='DLC',
|
||||
)
|
||||
|
||||
self.transformedfood = TransformedFood.objects.create(
|
||||
name='transformedfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
)
|
||||
|
||||
self.qrcode = QRCode.objects.create(
|
||||
qr_code_number=1,
|
||||
food_container=self.basicfood,
|
||||
)
|
||||
|
||||
def test_allergen_api(self):
|
||||
"""
|
||||
Load Allergen API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
|
||||
|
||||
def test_basicfood_api(self):
|
||||
"""
|
||||
Load BasicFood API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
|
||||
|
||||
def test_transformedfood_api(self):
|
||||
"""
|
||||
Load TransformedFood API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
|
||||
|
||||
def test_qrcode_api(self):
|
||||
"""
|
||||
Load QRCode API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')
|
@ -8,14 +8,15 @@ from . import views
|
||||
app_name = 'food'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.TransformedListView.as_view(), name='food_list'),
|
||||
path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'),
|
||||
path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'),
|
||||
|
||||
path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||
path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'),
|
||||
path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'),
|
||||
path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'),
|
||||
path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'),
|
||||
path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
||||
path('', views.FoodListView.as_view(), name='food_list'),
|
||||
path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||
path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
|
||||
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
|
||||
path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
|
||||
path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
|
||||
path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
|
||||
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
|
||||
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
|
||||
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
||||
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'),
|
||||
]
|
||||
|
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,523 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.db import transaction
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from datetime import timedelta
|
||||
|
||||
from api.viewsets import is_regex
|
||||
from django_tables2.views import MultiTableMixin
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from django.views.generic import DetailView, UpdateView
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect, Http404
|
||||
from django.views.generic import DetailView, UpdateView, CreateView
|
||||
from django.views.generic.list import ListView
|
||||
from django.forms import HiddenInput
|
||||
from django.views.generic.base import RedirectView
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club, Membership
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
|
||||
|
||||
from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms
|
||||
from .models import BasicFood, Food, QRCode, TransformedFood
|
||||
from .tables import TransformedFoodTable
|
||||
from .models import Food, BasicFood, TransformedFood, QRCode
|
||||
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
|
||||
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
|
||||
BasicFoodUpdateForms, TransformedFoodUpdateForms
|
||||
from .tables import FoodTable
|
||||
from .utils import pretty_duration
|
||||
|
||||
|
||||
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
|
||||
class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||
"""
|
||||
A view to add an ingredient
|
||||
Display Food
|
||||
"""
|
||||
model = Food
|
||||
template_name = 'food/add_ingredient_form.html'
|
||||
extra_context = {"title": _("Add the ingredient")}
|
||||
form_class = AddIngredientForms
|
||||
tables = [FoodTable, FoodTable, FoodTable, ]
|
||||
extra_context = {"title": _('Food')}
|
||||
template_name = 'food/food_list.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["pk"] = self.kwargs["pk"]
|
||||
return context
|
||||
def get_queryset(self, **kwargs):
|
||||
return super().get_queryset(**kwargs).distinct()
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.creater = self.request.user
|
||||
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||
add_ingredient_form = AddIngredientForms(data=self.request.POST)
|
||||
if food.is_ready:
|
||||
form.add_error(None, _("The product is already prepared"))
|
||||
return self.form_invalid(form)
|
||||
if not add_ingredient_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
def get_tables(self):
|
||||
bureau_role_pk = 4
|
||||
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
|
||||
user=self.request.user, roles=bureau_role_pk).filter(
|
||||
date_end__gte=timezone.now()))
|
||||
|
||||
# We flip logic ""fully used = not is_active""
|
||||
food.is_active = not food.is_active
|
||||
# Save the aliment and the allergens associed
|
||||
for transformed_pk in self.request.POST.getlist('ingredient'):
|
||||
transformed = TransformedFood.objects.get(pk=transformed_pk)
|
||||
if not transformed.is_ready:
|
||||
transformed.ingredient.add(food)
|
||||
transformed.update()
|
||||
food.save()
|
||||
tables = [FoodTable] * (clubs.count() + 3)
|
||||
self.tables = tables
|
||||
tables = super().get_tables()
|
||||
tables[0].prefix = 'search-'
|
||||
tables[1].prefix = 'open-'
|
||||
tables[2].prefix = 'served-'
|
||||
for i in range(clubs.count()):
|
||||
tables[i + 3].prefix = clubs[i].name
|
||||
return tables
|
||||
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
def get_tables_data(self):
|
||||
# table search
|
||||
qs = self.get_queryset().order_by('name')
|
||||
if "search" in self.request.GET and self.request.GET['search']:
|
||||
pattern = self.request.GET['search']
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse('food:food_list')
|
||||
|
||||
|
||||
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
A view to update a basic food
|
||||
"""
|
||||
model = BasicFood
|
||||
form_class = BasicFoodForms
|
||||
template_name = 'food/basicfood_form.html'
|
||||
extra_context = {"title": _("Update an aliment")}
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.creater = self.request.user
|
||||
basic_food_form = BasicFoodForms(data=self.request.POST)
|
||||
if not basic_food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
ans = super().form_valid(form)
|
||||
form.instance.update()
|
||||
return ans
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
return context
|
||||
|
||||
|
||||
class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
A view to see a food
|
||||
"""
|
||||
model = Food
|
||||
extra_context = {"title": _("Details of:")}
|
||||
context_object_name = "food"
|
||||
# check regex
|
||||
valid_regex = is_regex(pattern)
|
||||
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||
prefix = '^' if valid_regex else ''
|
||||
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
|
||||
| Q(**{f'owner__name{suffix}': prefix + pattern}))
|
||||
else:
|
||||
qs = qs.none()
|
||||
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||
# table open
|
||||
open_table = self.get_queryset().order_by('expiry_date').filter(
|
||||
Q(polymorphic_ctype__model='transformedfood')
|
||||
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
|
||||
expiry_date__lt=timezone.now(), end_of_life='').filter(
|
||||
PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||
# table served
|
||||
served_table = self.get_queryset().order_by('-pk').filter(
|
||||
end_of_life='', is_ready=True).exclude(
|
||||
Q(polymorphic_ctype__model='basicfood',
|
||||
basicfood__date_type='DLC',
|
||||
expiry_date__lte=timezone.now(),)
|
||||
| Q(polymorphic_ctype__model='transformedfood',
|
||||
expiry_date__lte=timezone.now(),
|
||||
))
|
||||
# tables club
|
||||
bureau_role_pk = 4
|
||||
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
|
||||
user=self.request.user, roles=bureau_role_pk).filter(
|
||||
date_end__gte=timezone.now()))
|
||||
club_table = []
|
||||
for club in clubs:
|
||||
club_table.append(self.get_queryset().order_by('expiry_date').filter(
|
||||
owner=club, end_of_life='').filter(
|
||||
PermissionBackend.filter_queryset(self.request, Food, 'view')
|
||||
))
|
||||
return [search_table, open_table, served_table] + club_table
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
||||
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||
tables = context['tables']
|
||||
# for extends base_search.html we need to name 'search_table' in 'table'
|
||||
for name, table in zip(['table', 'open', 'served'], tables):
|
||||
context[name] = table
|
||||
context['club_tables'] = tables[3:]
|
||||
|
||||
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
|
||||
return context
|
||||
|
||||
|
||||
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
#####################################################################
|
||||
# TO DO
|
||||
# - this feature is very pratical for meat or fish, nevertheless we can implement this later
|
||||
# - fix picture save
|
||||
# - implement solution crop and convert image (reuse or recode ImageForm from members apps)
|
||||
#####################################################################
|
||||
class QRCodeCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
A view to add a basic food with a qrcode
|
||||
"""
|
||||
model = BasicFood
|
||||
form_class = BasicFoodForms
|
||||
template_name = 'food/basicfood_form.html'
|
||||
extra_context = {"title": _("Add a new basic food with QRCode")}
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.creater = self.request.user
|
||||
basic_food_form = BasicFoodForms(data=self.request.POST)
|
||||
if not basic_food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Save the aliment and the allergens associed
|
||||
basic_food = form.save(commit=False)
|
||||
# We assume the date of labeling and the same as the date of arrival
|
||||
basic_food.arrival_date = timezone.now()
|
||||
basic_food.is_ready = False
|
||||
basic_food.is_active = True
|
||||
basic_food.was_eaten = False
|
||||
basic_food._force_save = True
|
||||
basic_food.save()
|
||||
basic_food.refresh_from_db()
|
||||
|
||||
qrcode = QRCode()
|
||||
qrcode.qr_code_number = self.kwargs['slug']
|
||||
qrcode.food_container = basic_food
|
||||
qrcode.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
|
||||
|
||||
def get_sample_object(self):
|
||||
|
||||
# We choose a club which may work or BDE else
|
||||
owner_id = 1
|
||||
for membership in self.request.user.memberships.all():
|
||||
club_id = membership.club.id
|
||||
food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id)
|
||||
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
|
||||
owner_id = club_id
|
||||
|
||||
return BasicFood(
|
||||
name="",
|
||||
expiry_date=timezone.now(),
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# Some field are hidden on create
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
form = context['form']
|
||||
form.fields['is_active'].widget = HiddenInput()
|
||||
form.fields['was_eaten'].widget = HiddenInput()
|
||||
|
||||
copy = self.request.GET.get('copy', None)
|
||||
if copy is not None:
|
||||
basic = BasicFood.objects.get(pk=copy)
|
||||
for field in ['date_type', 'expiry_date', 'name', 'owner']:
|
||||
form.fields[field].initial = getattr(basic, field)
|
||||
for field in ['allergens']:
|
||||
form.fields[field].initial = getattr(basic, field).all()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
A view to add a new qrcode
|
||||
A view to add qrcode
|
||||
"""
|
||||
model = QRCode
|
||||
template_name = 'food/create_qrcode_form.html'
|
||||
template_name = 'food/qrcode.html'
|
||||
form_class = QRCodeForms
|
||||
extra_context = {"title": _("Add a new QRCode")}
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
qrcode = kwargs["slug"]
|
||||
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
|
||||
return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs))
|
||||
pk = self.model.objects.get(qr_code_number=qrcode).food_container.pk
|
||||
return HttpResponseRedirect(reverse_lazy("food:food_view", kwargs={"pk": pk}))
|
||||
else:
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["slug"] = self.kwargs["slug"]
|
||||
|
||||
context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10]
|
||||
|
||||
return context
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.creater = self.request.user
|
||||
qrcode_food_form = QRCodeForms(data=self.request.POST)
|
||||
if not qrcode_food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Save the qrcode
|
||||
qrcode = form.save(commit=False)
|
||||
qrcode.qr_code_number = self.kwargs["slug"]
|
||||
qrcode.qr_code_number = self.kwargs['slug']
|
||||
qrcode._force_save = True
|
||||
qrcode.save()
|
||||
qrcode.refresh_from_db()
|
||||
return super().form_valid(form)
|
||||
|
||||
qrcode.food_container.save()
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['slug'] = self.kwargs['slug']
|
||||
|
||||
# get last 10 BasicFood objects with distincts 'name' ordered by '-pk'
|
||||
# we can't use .distinct and .order_by with differents columns hence the generator
|
||||
context['last_items'] = [food for food in BasicFood.get_lastests_objects(10, 'name', '-pk')]
|
||||
return context
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy('food:food_view', kwargs={'pk': self.object.food_container.pk})
|
||||
|
||||
def get_sample_object(self):
|
||||
return QRCode(
|
||||
qr_code_number=self.kwargs['slug'],
|
||||
food_container_id=1,
|
||||
)
|
||||
|
||||
|
||||
class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
A view to add basicfood
|
||||
"""
|
||||
model = BasicFood
|
||||
form_class = BasicFoodForms
|
||||
extra_context = {"title": _("Add an aliment")}
|
||||
template_name = "food/food_update.html"
|
||||
|
||||
def get_sample_object(self):
|
||||
# We choose a club which may work or BDE else
|
||||
food = BasicFood(
|
||||
name="",
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
arrival_date=timezone.now(),
|
||||
date_type='DLC',
|
||||
)
|
||||
|
||||
for membership in self.request.user.memberships.all():
|
||||
club_id = membership.club.id
|
||||
food.owner_id = club_id
|
||||
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
|
||||
return food
|
||||
|
||||
return food
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
if QRCode.objects.filter(qr_code_number=self.kwargs['slug']).count() > 0:
|
||||
return HttpResponseRedirect(reverse_lazy('food:qrcode_create', kwargs={'slug': self.kwargs['slug']}))
|
||||
food_form = BasicFoodForms(data=self.request.POST)
|
||||
if not food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
food = form.save(commit=False)
|
||||
food.is_ready = False
|
||||
food.save()
|
||||
food.refresh_from_db()
|
||||
|
||||
qrcode = QRCode()
|
||||
qrcode.qr_code_number = self.kwargs['slug']
|
||||
qrcode.food_container = food
|
||||
qrcode.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
|
||||
return reverse_lazy('food:basicfood_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_sample_object(self):
|
||||
return QRCode(
|
||||
qr_code_number=self.kwargs["slug"],
|
||||
food_container_id=1
|
||||
)
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
|
||||
copy = self.request.GET.get('copy', None)
|
||||
if copy is not None:
|
||||
food = BasicFood.objects.get(pk=copy)
|
||||
print(context['form'].fields)
|
||||
for field in context['form'].fields:
|
||||
if field == 'allergens':
|
||||
context['form'].fields[field].initial = getattr(food, field).all()
|
||||
else:
|
||||
context['form'].fields[field].initial = getattr(food, field)
|
||||
|
||||
class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
A view to see a qrcode
|
||||
"""
|
||||
model = QRCode
|
||||
extra_context = {"title": _("QRCode")}
|
||||
context_object_name = "qrcode"
|
||||
slug_field = "qr_code_number"
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
qrcode = kwargs["slug"]
|
||||
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
|
||||
return super().get(*args, **kwargs)
|
||||
else:
|
||||
return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
qr_code_number = self.kwargs['slug']
|
||||
qrcode = self.model.objects.get(qr_code_number=qr_code_number)
|
||||
|
||||
model = qrcode.food_container.polymorphic_ctype.model
|
||||
|
||||
if model == "basicfood":
|
||||
context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood")
|
||||
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood")
|
||||
if model == "transformedfood":
|
||||
context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood")
|
||||
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||
return context
|
||||
|
||||
|
||||
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
A view to add a tranformed food
|
||||
A view to add transformedfood
|
||||
"""
|
||||
model = TransformedFood
|
||||
template_name = 'food/transformedfood_form.html'
|
||||
form_class = TransformedFoodForms
|
||||
extra_context = {"title": _("Add a new meal")}
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.creater = self.request.user
|
||||
transformed_food_form = TransformedFoodForms(data=self.request.POST)
|
||||
if not transformed_food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Save the aliment and allergens associated
|
||||
transformed_food = form.save(commit=False)
|
||||
transformed_food.expiry_date = transformed_food.creation_date
|
||||
transformed_food.is_active = True
|
||||
transformed_food.is_ready = False
|
||||
transformed_food.was_eaten = False
|
||||
transformed_food._force_save = True
|
||||
transformed_food.save()
|
||||
transformed_food.refresh_from_db()
|
||||
ans = super().form_valid(form)
|
||||
transformed_food.update()
|
||||
return ans
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||
extra_context = {"title": _("Add a meal")}
|
||||
template_name = "food/food_update.html"
|
||||
|
||||
def get_sample_object(self):
|
||||
# We choose a club which may work or BDE else
|
||||
owner_id = 1
|
||||
for membership in self.request.user.memberships.all():
|
||||
club_id = membership.club.id
|
||||
food = TransformedFood(name="",
|
||||
creation_date=timezone.now(),
|
||||
expiry_date=timezone.now(),
|
||||
owner_id=club_id)
|
||||
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
|
||||
owner_id = club_id
|
||||
break
|
||||
|
||||
return TransformedFood(
|
||||
food = TransformedFood(
|
||||
name="",
|
||||
owner_id=owner_id,
|
||||
creation_date=timezone.now(),
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
for membership in self.request.user.memberships.all():
|
||||
club_id = membership.club.id
|
||||
food.owner_id = club_id
|
||||
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
|
||||
return food
|
||||
|
||||
# Some field are hidden on create
|
||||
form = context['form']
|
||||
form.fields['is_active'].widget = HiddenInput()
|
||||
form.fields['is_ready'].widget = HiddenInput()
|
||||
form.fields['was_eaten'].widget = HiddenInput()
|
||||
form.fields['shelf_life'].widget = HiddenInput()
|
||||
return food
|
||||
|
||||
return context
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.expiry_date = timezone.now() + timedelta(days=3)
|
||||
form.instance.is_ready = False
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
MAX_FORMS = 100
|
||||
|
||||
|
||||
class ManageIngredientsView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
A view to update transformed product
|
||||
A view to manage ingredient for a transformed food
|
||||
"""
|
||||
model = TransformedFood
|
||||
template_name = 'food/transformedfood_form.html'
|
||||
form_class = TransformedFoodForms
|
||||
extra_context = {'title': _('Update a meal')}
|
||||
fields = ['ingredients']
|
||||
extra_context = {"title": _("Manage ingredients of:")}
|
||||
template_name = 'food/manage_ingredients.html'
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
old_ingredients = list(self.object.ingredients.all()).copy()
|
||||
old_allergens = list(self.object.allergens.all()).copy()
|
||||
self.object.ingredients.clear()
|
||||
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
|
||||
prefix = 'form-' + str(i) + '-'
|
||||
if form.data[prefix + 'qrcode'] not in ['0', '']:
|
||||
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container
|
||||
self.object.ingredients.add(ingredient)
|
||||
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
|
||||
ingredient.end_of_life = _('Fully used in {meal}'.format(
|
||||
meal=self.object.name))
|
||||
ingredient.save()
|
||||
|
||||
elif form.data[prefix + 'name'] != '':
|
||||
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
|
||||
self.object.ingredients.add(ingredient)
|
||||
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
|
||||
ingredient.end_of_life = _('Fully used in {meal}'.format(
|
||||
meal=self.object.name))
|
||||
ingredient.save()
|
||||
# We recalculate new expiry date and allergens
|
||||
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
|
||||
self.object.allergens.clear()
|
||||
|
||||
for ingredient in self.object.ingredients.iterator():
|
||||
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
|
||||
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
|
||||
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
|
||||
|
||||
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['title'] += ' ' + self.object.name
|
||||
formset = ManageIngredientsFormSet()
|
||||
ingredients = self.object.ingredients.all()
|
||||
formset.extra += ingredients.count() + MAX_FORMS
|
||||
context['form'] = ManageIngredientsForm()
|
||||
context['ingredients_count'] = ingredients.count()
|
||||
display = [True] * (1 + ingredients.count()) + [False] * (formset.extra - ingredients.count() - 1)
|
||||
context['formset'] = zip(display, formset)
|
||||
context['ingredients'] = []
|
||||
for ingredient in ingredients:
|
||||
qr = QRCode.objects.filter(food_container=ingredient)
|
||||
|
||||
context['ingredients'].append({
|
||||
'food_pk': ingredient.pk,
|
||||
'food_name': ingredient.name,
|
||||
'qr_pk': '' if qr.count() == 0 else qr[0].pk,
|
||||
'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number,
|
||||
'fully_used': 'true' if ingredient.end_of_life else '',
|
||||
})
|
||||
return context
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
A view to add ingredient to a meal
|
||||
"""
|
||||
model = Food
|
||||
extra_context = {"title": _("Add the ingredient:")}
|
||||
form_class = AddIngredientForms
|
||||
template_name = 'food/food_update.html'
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['title'] += ' ' + self.object.name
|
||||
return context
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
meals = TransformedFood.objects.filter(pk__in=form.data.getlist('ingredients')).all()
|
||||
if not meals:
|
||||
return HttpResponseRedirect(reverse_lazy('food:food_view', kwargs={"pk": self.object.pk}))
|
||||
for meal in meals:
|
||||
old_ingredients = list(meal.ingredients.all()).copy()
|
||||
old_allergens = list(meal.allergens.all()).copy()
|
||||
meal.ingredients.add(self.object.pk)
|
||||
# update allergen and expiry date if necessary
|
||||
if not (self.object.polymorphic_ctype.model == 'basicfood'
|
||||
and self.object.date_type == 'DDM'):
|
||||
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
|
||||
meal.allergens.set(meal.allergens.union(self.object.allergens.all()))
|
||||
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
|
||||
if 'fully_used' in form.data:
|
||||
if not self.object.end_of_life:
|
||||
self.object.end_of_life = _(f'Food fully used in : {meal.name}')
|
||||
else:
|
||||
self.object.end_of_life += ', ' + meal.name
|
||||
if 'fully_used' in form.data:
|
||||
self.object.is_ready = False
|
||||
self.object.save()
|
||||
# We redirect only the first parent
|
||||
parent_pk = meals[0].pk
|
||||
return HttpResponseRedirect(self.get_success_url(parent_pk=parent_pk))
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy('food:transformedfood_view', kwargs={"pk": kwargs['parent_pk']})
|
||||
|
||||
|
||||
class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
A view to update Food
|
||||
"""
|
||||
model = Food
|
||||
extra_context = {"title": _("Update an aliment")}
|
||||
template_name = 'food/food_update.html'
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.creater = self.request.user
|
||||
transformedfood_form = TransformedFoodForms(data=self.request.POST)
|
||||
if not transformedfood_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||
old_allergens = list(food.allergens.all()).copy()
|
||||
|
||||
if food.polymorphic_ctype.model == 'transformedfood':
|
||||
old_ingredients = food.ingredients.all()
|
||||
form.instance.shelf_life = timedelta(
|
||||
seconds=int(form.data['shelf_life']) * 60 * 60)
|
||||
|
||||
food_form = self.get_form_class()(data=self.request.POST)
|
||||
if not food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
ans = super().form_valid(form)
|
||||
form.instance.update()
|
||||
if food.polymorphic_ctype.model == 'transformedfood':
|
||||
form.instance.save(old_ingredients=old_ingredients)
|
||||
else:
|
||||
form.instance.save(old_allergens=old_allergens)
|
||||
return ans
|
||||
|
||||
def get_form_class(self, **kwargs):
|
||||
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||
if food.polymorphic_ctype.model == 'basicfood':
|
||||
return BasicFoodUpdateForms
|
||||
else:
|
||||
return TransformedFoodUpdateForms
|
||||
|
||||
def get_form(self, **kwargs):
|
||||
form = super().get_form(**kwargs)
|
||||
if 'shelf_life' in form.initial:
|
||||
hours = form.initial['shelf_life'].days * 24 + form.initial['shelf_life'].seconds // 3600
|
||||
form.initial['shelf_life'] = hours
|
||||
return form
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||
return reverse_lazy('food:food_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
A view to see a food
|
||||
"""
|
||||
model = Food
|
||||
extra_context = {"title": _('Details of:')}
|
||||
context_object_name = "food"
|
||||
template_name = "food/food_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"]
|
||||
|
||||
fields = dict([(field, getattr(self.object, field)) for field in fields])
|
||||
if fields["is_ready"]:
|
||||
fields["is_ready"] = _("Yes")
|
||||
else:
|
||||
fields["is_ready"] = _("No")
|
||||
fields["allergens"] = ", ".join(
|
||||
allergen.name for allergen in fields["allergens"].all())
|
||||
|
||||
context["fields"] = [(
|
||||
Food._meta.get_field(field).verbose_name.capitalize(),
|
||||
value) for field, value in fields.items()]
|
||||
if self.object.QR_code.exists():
|
||||
context["QR_code"] = self.object.QR_code.first()
|
||||
context["meals"] = self.object.transformed_ingredient_inv.all()
|
||||
context["update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
||||
context["add_ingredient"] = (self.object.end_of_life == '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood"))
|
||||
return context
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
if Food.objects.filter(pk=kwargs['pk']).count() != 1:
|
||||
return Http404
|
||||
model = Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model
|
||||
if 'stop_redirect' in kwargs and kwargs['stop_redirect']:
|
||||
return super().get(*args, **kwargs)
|
||||
kwargs = {'pk': kwargs['pk']}
|
||||
if model == 'basicfood':
|
||||
return HttpResponseRedirect(reverse_lazy("food:basicfood_view", kwargs=kwargs))
|
||||
return HttpResponseRedirect(reverse_lazy("food:transformedfood_view", kwargs=kwargs))
|
||||
|
||||
class TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||
"""
|
||||
Displays ready TransformedFood
|
||||
"""
|
||||
model = TransformedFood
|
||||
tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable]
|
||||
extra_context = {"title": _("Transformed food")}
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
return super().get_queryset(**kwargs).distinct()
|
||||
|
||||
def get_tables(self):
|
||||
tables = super().get_tables()
|
||||
|
||||
tables[0].prefix = "all-"
|
||||
tables[1].prefix = "open-"
|
||||
tables[2].prefix = "served-"
|
||||
return tables
|
||||
|
||||
def get_tables_data(self):
|
||||
# first table = all transformed food, second table = free, third = served
|
||||
return [
|
||||
self.get_queryset().order_by("-creation_date"),
|
||||
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now())
|
||||
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
|
||||
.distinct()
|
||||
.order_by("-creation_date"),
|
||||
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now())
|
||||
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
|
||||
.distinct()
|
||||
.order_by("-creation_date")
|
||||
]
|
||||
|
||||
class BasicFoodDetailView(FoodDetailView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# We choose a club which should work
|
||||
for membership in self.request.user.memberships.all():
|
||||
club_id = membership.club.id
|
||||
food = TransformedFood(
|
||||
name="",
|
||||
owner_id=club_id,
|
||||
creation_date=timezone.now(),
|
||||
expiry_date=timezone.now(),
|
||||
)
|
||||
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
|
||||
context['can_create_meal'] = True
|
||||
break
|
||||
|
||||
tables = context["tables"]
|
||||
for name, table in zip(["table", "open", "served"], tables):
|
||||
context[name] = table
|
||||
fields = ['arrival_date', 'date_type']
|
||||
for field in fields:
|
||||
context["fields"].append((
|
||||
BasicFood._meta.get_field(field).verbose_name.capitalize(),
|
||||
getattr(self.object, field)
|
||||
))
|
||||
return context
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
if Food.objects.filter(pk=kwargs['pk']).count() == 1:
|
||||
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'basicfood')
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
|
||||
class TransformedFoodDetailView(FoodDetailView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["fields"].append((
|
||||
TransformedFood._meta.get_field("creation_date").verbose_name.capitalize(),
|
||||
self.object.creation_date
|
||||
))
|
||||
context["fields"].append((
|
||||
TransformedFood._meta.get_field("shelf_life").verbose_name.capitalize(),
|
||||
pretty_duration(self.object.shelf_life)
|
||||
))
|
||||
context["foods"] = self.object.ingredients.all()
|
||||
context["manage_ingredients"] = True
|
||||
return context
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
if Food.objects.filter(pk=kwargs['pk']).count() == 1:
|
||||
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood')
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
|
||||
class QRCodeRedirectView(RedirectView):
|
||||
"""
|
||||
Redirects to the QR code creation page from Food List
|
||||
"""
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
slug = self.request.GET.get('slug')
|
||||
if slug:
|
||||
return reverse_lazy('food:qrcode_create', kwargs={'slug': slug})
|
||||
return reverse_lazy('food:list')
|
||||
|
@ -23,7 +23,7 @@ from .models import Profile, Club, Membership
|
||||
class CustomAuthenticationForm(AuthenticationForm):
|
||||
permission_mask = forms.ModelChoiceField(
|
||||
label=_("Permission mask"),
|
||||
queryset=PermissionMask.objects.order_by("rank"),
|
||||
queryset=PermissionMask.objects.order_by("-rank"),
|
||||
empty_label=None,
|
||||
)
|
||||
|
||||
|
46
apps/member/migrations/0014_create_bda.py
Normal file
46
apps/member/migrations/0014_create_bda.py
Normal file
@ -0,0 +1,46 @@
|
||||
from django.db import migrations
|
||||
|
||||
def create_bda(apps, schema_editor):
|
||||
"""
|
||||
The club BDA is now pre-injected.
|
||||
"""
|
||||
Club = apps.get_model("member", "club")
|
||||
NoteClub = apps.get_model("note", "noteclub")
|
||||
Alias = apps.get_model("note", "alias")
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
|
||||
|
||||
Club.objects.get_or_create(
|
||||
id=10,
|
||||
name="BDA",
|
||||
email="bda.ensparissaclay@gmail.com",
|
||||
require_memberships=True,
|
||||
membership_fee_paid=750,
|
||||
membership_fee_unpaid=750,
|
||||
membership_duration=396,
|
||||
membership_start="2024-08-01",
|
||||
membership_end="2025-09-30",
|
||||
)
|
||||
NoteClub.objects.get_or_create(
|
||||
id=1937,
|
||||
club_id=10,
|
||||
polymorphic_ctype_id=polymorphic_ctype_id,
|
||||
)
|
||||
Alias.objects.get_or_create(
|
||||
id=1937,
|
||||
note_id=1937,
|
||||
name="BDA",
|
||||
normalized_name="bda",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0013_auto_20240801_1436'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_bda),
|
||||
]
|
||||
|
@ -438,8 +438,6 @@ class Membership(models.Model):
|
||||
)
|
||||
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
|
||||
new_membership._force_renew_parent = True
|
||||
if hasattr(self, '_soge') and self._soge:
|
||||
new_membership._soge = True
|
||||
if hasattr(self, '_force_save') and self._force_save:
|
||||
new_membership._force_save = True
|
||||
new_membership.save()
|
||||
@ -458,8 +456,6 @@ class Membership(models.Model):
|
||||
# Renew the previous membership of the parent club
|
||||
parent_membership = parent_membership.first()
|
||||
parent_membership._force_renew_parent = True
|
||||
if hasattr(self, '_soge'):
|
||||
parent_membership._soge = True
|
||||
if hasattr(self, '_force_save'):
|
||||
parent_membership._force_save = True
|
||||
parent_membership.renew()
|
||||
@ -471,8 +467,6 @@ class Membership(models.Model):
|
||||
date_start=self.date_start,
|
||||
)
|
||||
parent_membership._force_renew_parent = True
|
||||
if hasattr(self, '_soge'):
|
||||
parent_membership._soge = True
|
||||
if hasattr(self, '_force_save'):
|
||||
parent_membership._force_save = True
|
||||
parent_membership.save()
|
||||
|
@ -60,7 +60,10 @@
|
||||
{% if user_object.pk == user.pk %}
|
||||
<div class="text-center">
|
||||
<a class="small badge badge-secondary" href="{% url 'member:auth_token' %}">
|
||||
<i class="fa fa-cogs"></i>{% trans 'API token' %}
|
||||
<i class="fa fa-cogs"></i> {% trans 'API token' %}
|
||||
</a>
|
||||
<a class="small badge badge-secondary" href="{% url 'member:qr_code' user_object.pk %}">
|
||||
<i class="fa fa-qrcode"></i> {% trans 'QR Code' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -20,12 +20,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</form>
|
||||
</div>
|
||||
<!-- MODAL TO CROP THE IMAGE -->
|
||||
<div class="modal fade" id="modalCrop">
|
||||
<div class="modal fade" id="modalCrop" data-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body">
|
||||
<img src="" id="modal-image" style="max-width: 100%;">
|
||||
</div>
|
||||
<div class="modal-body-wrapper" style="width: 500px; height: 500px; padding: 16px;">
|
||||
<div class="modal-body" style="width: 100%; height: 100%; padding: 0">
|
||||
<img src="" id="modal-image" style="display: block; max-width: 100%;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="btn-group pull-left" role="group">
|
||||
<button type="button" class="btn btn-default" id="js-zoom-in">
|
||||
|
36
apps/member/templates/member/qr_code.html
Normal file
36
apps/member/templates/member/qr_code.html
Normal file
@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-light">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "QR Code for" %} {{ user_object.username }} ({{ user_object.first_name }} {{user_object.last_name }})
|
||||
</h3>
|
||||
<div class="text-center" id="qrcode">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script>
|
||||
var qrc = new QRCode(document.getElementById("qrcode"), {
|
||||
text: "{{ user_object.pk }}\0",
|
||||
width: 1024,
|
||||
height: 1024
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extracss %}
|
||||
<style>
|
||||
img {
|
||||
width: 100%
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
@ -44,7 +44,7 @@ class TemplateLoggedInTests(TestCase):
|
||||
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
|
||||
|
||||
def test_logout(self):
|
||||
response = self.client.get(reverse("logout"))
|
||||
response = self.client.post(reverse("logout"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_admin_index(self):
|
||||
|
@ -25,4 +25,5 @@ urlpatterns = [
|
||||
path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
|
||||
path('user/<int:pk>/trust', views.ProfileTrustView.as_view(), name="user_trust"),
|
||||
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'),
|
||||
path('user/<int:pk>/qr_code/', views.QRCodeView.as_view(), name='qr_code'),
|
||||
]
|
||||
|
@ -402,6 +402,14 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
|
||||
context['token'] = Token.objects.get_or_create(user=self.request.user)[0]
|
||||
return context
|
||||
|
||||
class QRCodeView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Affiche le QR Code
|
||||
"""
|
||||
model = User
|
||||
context_object_name = "user_object"
|
||||
template_name = "member/qr_code.html"
|
||||
extra_context = {"title": _("QR Code")}
|
||||
|
||||
# ******************************* #
|
||||
# CLUB #
|
||||
|
@ -13,7 +13,7 @@ def register_note_urls(router, path):
|
||||
router.register(path + '/note', NotePolymorphicViewSet)
|
||||
router.register(path + '/alias', AliasViewSet)
|
||||
router.register(path + '/trust', TrustViewSet)
|
||||
router.register(path + '/consumer', ConsumerViewSet)
|
||||
router.register(path + '/consumer', ConsumerViewSet, basename='alias2')
|
||||
|
||||
router.register(path + '/transaction/category', TemplateCategoryViewSet)
|
||||
router.register(path + '/transaction/transaction', TransactionViewSet)
|
||||
|
@ -89,7 +89,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</ul>
|
||||
<div class="card-body">
|
||||
<select id="debit_type" class="form-control custom-select d-none">
|
||||
{% for special_type in special_types %}
|
||||
{% for special_type in special_types|slice:"::-1" %}
|
||||
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,10 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||
from oauth2_provider.scopes import BaseScopes
|
||||
from member.models import Club
|
||||
from note.models import Alias
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
from .backends import PermissionBackend
|
||||
@ -16,26 +18,58 @@ class PermissionScopes(BaseScopes):
|
||||
and can be useful to make queries through the API with limited privileges.
|
||||
"""
|
||||
|
||||
def get_all_scopes(self):
|
||||
return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
||||
def get_all_scopes(self, **kwargs):
|
||||
scopes = {}
|
||||
if 'scopes' in kwargs:
|
||||
for scope in kwargs['scopes']:
|
||||
if scope == 'openid':
|
||||
scopes['openid'] = "OpenID Connect"
|
||||
else:
|
||||
p = Permission.objects.get(id=scope.split('_')[0])
|
||||
club = Club.objects.get(id=scope.split('_')[1])
|
||||
scopes[scope] = f"{p.description} (club {club.name})"
|
||||
return scopes
|
||||
|
||||
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
|
||||
for p in Permission.objects.all() for club in Club.objects.all()}
|
||||
scopes['openid'] = "OpenID Connect"
|
||||
return scopes
|
||||
|
||||
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
if not application:
|
||||
return []
|
||||
return [f"{p.id}_{p.membership.club.id}"
|
||||
for t in Permission.PERMISSION_TYPES
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for t in Permission.PERMISSION_TYPES
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||
scopes.append('openid')
|
||||
return scopes
|
||||
|
||||
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
if not application:
|
||||
return []
|
||||
return [f"{p.id}_{p.membership.club.id}"
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||
scopes.append('openid')
|
||||
return scopes
|
||||
|
||||
|
||||
class PermissionOAuth2Validator(OAuth2Validator):
|
||||
oidc_claim_scope = None # fix breaking change of django-oauth-toolkit 2.0.0
|
||||
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
|
||||
oidc_claim_scope.update({"name": 'openid',
|
||||
"normalized_name": 'openid',
|
||||
"email": 'openid',
|
||||
})
|
||||
|
||||
def get_additional_claims(self, request):
|
||||
return {
|
||||
"name": request.user.username,
|
||||
"normalized_name": Alias.normalize(request.user.username),
|
||||
"email": request.user.email,
|
||||
}
|
||||
|
||||
def get_discovery_claims(self, request):
|
||||
claims = super().get_discovery_claims(self)
|
||||
return claims + ["name", "normalized_name", "email"]
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
@ -54,6 +88,8 @@ class PermissionOAuth2Validator(OAuth2Validator):
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
request.scopes = valid_scopes
|
||||
if 'openid' in scopes:
|
||||
valid_scopes.add('openid')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
@ -13,12 +13,14 @@ EXCLUDED = [
|
||||
'cas_server.serviceticket',
|
||||
'cas_server.user',
|
||||
'cas_server.userattributes',
|
||||
'constance.constance',
|
||||
'contenttypes.contenttype',
|
||||
'logs.changelog',
|
||||
'migrations.migration',
|
||||
'oauth2_provider.accesstoken',
|
||||
'oauth2_provider.grant',
|
||||
'oauth2_provider.refreshtoken',
|
||||
'oauth2_provider.idtoken',
|
||||
'sessions.session',
|
||||
]
|
||||
|
||||
|
@ -10,7 +10,7 @@ from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from activity.models import Activity
|
||||
from member.models import Club, Membership
|
||||
from note.models import NoteUser
|
||||
from note.models import NoteUser, NoteClub
|
||||
from wei.models import WEIClub, Bus, WEIRegistration
|
||||
|
||||
|
||||
@ -122,10 +122,13 @@ class TestPermissionDenied(TestCase):
|
||||
|
||||
def test_validate_weiregistration(self):
|
||||
wei = WEIClub.objects.create(
|
||||
name="WEI Test",
|
||||
membership_start=date.today(),
|
||||
date_start=date.today() + timedelta(days=1),
|
||||
date_end=date.today() + timedelta(days=1),
|
||||
parent_club=Club.objects.get(name="Kfet"),
|
||||
)
|
||||
NoteClub.objects.create(club=wei)
|
||||
registration = WEIRegistration.objects.create(wei=wei, user=self.user, birth_date="2000-01-01")
|
||||
response = self.client.get(reverse("wei:validate_registration", kwargs=dict(pk=registration.pk)))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
@ -164,14 +164,24 @@ class ScopesView(LoginRequiredMixin, TemplateView):
|
||||
from oauth2_provider.models import Application
|
||||
from .scopes import PermissionScopes
|
||||
|
||||
scopes = PermissionScopes()
|
||||
oidc = False
|
||||
context["scopes"] = {}
|
||||
all_scopes = scopes.get_all_scopes()
|
||||
for app in Application.objects.filter(user=self.request.user).all():
|
||||
available_scopes = scopes.get_available_scopes(app)
|
||||
available_scopes = PermissionScopes().get_available_scopes(app)
|
||||
context["scopes"][app] = OrderedDict()
|
||||
items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes]
|
||||
all_scopes = PermissionScopes().get_all_scopes(scopes=available_scopes)
|
||||
scopes = {}
|
||||
for scope in available_scopes:
|
||||
scopes[scope] = all_scopes[scope]
|
||||
# remove OIDC scope for sort
|
||||
if 'openid' in scopes:
|
||||
del scopes['openid']
|
||||
oidc = True
|
||||
items = [(k, v) for (k, v) in scopes.items()]
|
||||
items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
|
||||
# add oidc if necessary
|
||||
if oidc:
|
||||
items.append(('openid', PermissionScopes().get_all_scopes(scopes=['openid'])['openid']))
|
||||
for k, v in items:
|
||||
context["scopes"][app][k] = v
|
||||
|
||||
|
18
apps/treasury/migrations/0010_alter_invoice_bde.py
Normal file
18
apps/treasury/migrations/0010_alter_invoice_bde.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.20 on 2025-04-14 20:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('treasury', '0009_alter_sogecredit_transactions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
name='bde',
|
||||
field=models.CharField(choices=[('Diolistos', 'Diol[list]os'), ('RavePartlist', 'RavePart[list]'), ('SecretStorlist', 'SecretStor[list]'), ('TotalistSpies', 'Tota[list]Spies'), ('Saperlistpopette', 'Saper[list]popette'), ('Finalist', 'Fina[list]'), ('Listorique', '[List]orique'), ('Satellist', 'Satel[list]'), ('Monopolist', 'Monopo[list]'), ('Kataclist', 'Katac[list]')], default='Diolistos', max_length=32, verbose_name='BDE'),
|
||||
),
|
||||
]
|
@ -27,8 +27,9 @@ class Invoice(models.Model):
|
||||
|
||||
bde = models.CharField(
|
||||
max_length=32,
|
||||
default='RavePartlist',
|
||||
default='Diolistos',
|
||||
choices=(
|
||||
('Diolistos', 'Diol[list]os'),
|
||||
('RavePartlist', 'RavePart[list]'),
|
||||
('SecretStorlist', 'SecretStor[list]'),
|
||||
('TotalistSpies', 'Tota[list]Spies'),
|
||||
@ -352,7 +353,7 @@ class SogeCredit(models.Model):
|
||||
def amount(self):
|
||||
if self.valid:
|
||||
return self.credit_transaction.total
|
||||
amount = sum(transaction.total for transaction in self.transactions.all())
|
||||
amount = sum(max(transaction.total - 2000, 0) for transaction in self.transactions.all())
|
||||
if 'wei' in settings.INSTALLED_APPS:
|
||||
from wei.models import WEIMembership
|
||||
if not WEIMembership.objects\
|
||||
@ -440,7 +441,7 @@ class SogeCredit(models.Model):
|
||||
With Great Power Comes Great Responsibility...
|
||||
"""
|
||||
|
||||
total_fee = sum(transaction.total for transaction in self.transactions.all() if not transaction.valid)
|
||||
total_fee = sum(max(transaction.total - 2000, 0) for transaction in self.transactions.all() if not transaction.valid)
|
||||
if self.user.note.balance < total_fee:
|
||||
raise ValidationError(_("This user doesn't have enough money to pay the memberships with its note. "
|
||||
"Please ask her/him to credit the note before invalidating this credit."))
|
||||
|
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 |
@ -77,7 +77,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet):
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email',
|
||||
'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name',
|
||||
'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender',
|
||||
'wei__email', 'wei__year', 'soge_credit', 'deposit_check', 'birth_date', 'gender',
|
||||
'clothing_cut', 'clothing_size', 'first_year', 'emergency_contact_name',
|
||||
'emergency_contact_phone', ]
|
||||
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email',
|
||||
|
@ -1,10 +1,11 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, WEIMembershipForm, BusForm, BusTeamForm
|
||||
from .registration import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, WEIMembership1AForm, \
|
||||
WEIMembershipForm, BusForm, BusTeamForm
|
||||
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
|
||||
|
||||
__all__ = [
|
||||
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||
'WEIForm', 'WEIRegistrationForm', 'WEIRegistration1AForm', 'WEIRegistration2AForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
|
||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||
]
|
||||
|
@ -5,7 +5,7 @@ from bootstrap_datepicker_plus.widgets import DatePickerInput
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.forms import CheckboxSelectMultiple, RadioSelect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note.models import NoteSpecial, NoteUser
|
||||
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
|
||||
@ -24,6 +24,8 @@ class WEIForm(forms.ModelForm):
|
||||
"membership_end": DatePickerInput(),
|
||||
"date_start": DatePickerInput(),
|
||||
"date_end": DatePickerInput(),
|
||||
"deposit_amount": AmountInput(),
|
||||
"fee_soge_credit": AmountInput(),
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +41,11 @@ class WEIRegistrationForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = WEIRegistration
|
||||
exclude = ('wei', 'clothing_cut')
|
||||
fields = [
|
||||
'user', 'soge_credit', 'birth_date', 'gender', 'clothing_size',
|
||||
'health_issues', 'emergency_contact_name', 'emergency_contact_phone',
|
||||
'first_year', 'information_json', 'deposit_check'
|
||||
]
|
||||
widgets = {
|
||||
"user": Autocomplete(
|
||||
User,
|
||||
@ -49,11 +55,30 @@ class WEIRegistrationForm(forms.ModelForm):
|
||||
'placeholder': 'Nom ...',
|
||||
},
|
||||
),
|
||||
"birth_date": DatePickerInput(options={'minDate': '1900-01-01',
|
||||
'maxDate': '2100-01-01'}),
|
||||
"birth_date": DatePickerInput(options={
|
||||
'minDate': '1900-01-01',
|
||||
'maxDate': '2100-01-01'
|
||||
}),
|
||||
"deposit_check": forms.BooleanField(
|
||||
required=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class WEIRegistration2AForm(WEIRegistrationForm):
|
||||
class Meta(WEIRegistrationForm.Meta):
|
||||
fields = WEIRegistrationForm.Meta.fields + ['deposit_type']
|
||||
widgets = WEIRegistrationForm.Meta.widgets.copy()
|
||||
widgets.update({
|
||||
"deposit_type": forms.RadioSelect(),
|
||||
})
|
||||
|
||||
|
||||
class WEIRegistration1AForm(WEIRegistrationForm):
|
||||
class Meta(WEIRegistrationForm.Meta):
|
||||
fields = WEIRegistrationForm.Meta.fields
|
||||
|
||||
|
||||
class WEIChooseBusForm(forms.Form):
|
||||
bus = forms.ModelMultipleChoiceField(
|
||||
queryset=Bus.objects,
|
||||
@ -72,22 +97,17 @@ class WEIChooseBusForm(forms.Form):
|
||||
)
|
||||
|
||||
roles = forms.ModelMultipleChoiceField(
|
||||
queryset=WEIRole.objects.filter(~Q(name="1A")),
|
||||
queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")),
|
||||
label=_("WEI Roles"),
|
||||
help_text=_("Select the roles that you are interested in."),
|
||||
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
|
||||
initial=WEIRole.objects.filter(Q(name="Adhérent⋅e WEI") | Q(name="\u00c9lectron libre")).all(),
|
||||
widget=CheckboxSelectMultiple(),
|
||||
)
|
||||
|
||||
|
||||
class WEIMembershipForm(forms.ModelForm):
|
||||
caution_check = forms.BooleanField(
|
||||
required=False,
|
||||
label=_("Caution check given"),
|
||||
)
|
||||
|
||||
roles = forms.ModelMultipleChoiceField(
|
||||
queryset=WEIRole.objects,
|
||||
queryset=WEIRole.objects.filter(~Q(name="GC WEI")),
|
||||
label=_("WEI Roles"),
|
||||
widget=CheckboxSelectMultiple(),
|
||||
)
|
||||
@ -121,6 +141,19 @@ class WEIMembershipForm(forms.ModelForm):
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, wei=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'bus' in self.fields:
|
||||
if wei is not None:
|
||||
self.fields['bus'].queryset = Bus.objects.filter(wei=wei)
|
||||
else:
|
||||
self.fields['bus'].queryset = Bus.objects.none()
|
||||
if 'team' in self.fields:
|
||||
if wei is not None:
|
||||
self.fields['team'].queryset = BusTeam.objects.filter(bus__wei=wei)
|
||||
else:
|
||||
self.fields['team'].queryset = BusTeam.objects.none()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if 'team' in cleaned_data and cleaned_data["team"] is not None \
|
||||
@ -132,21 +165,8 @@ class WEIMembershipForm(forms.ModelForm):
|
||||
model = WEIMembership
|
||||
fields = ('roles', 'bus', 'team',)
|
||||
widgets = {
|
||||
"bus": Autocomplete(
|
||||
Bus,
|
||||
attrs={
|
||||
'api_url': '/api/wei/bus/',
|
||||
'placeholder': 'Bus ...',
|
||||
}
|
||||
),
|
||||
"team": Autocomplete(
|
||||
BusTeam,
|
||||
attrs={
|
||||
'api_url': '/api/wei/team/',
|
||||
'placeholder': 'Équipe ...',
|
||||
},
|
||||
resetable=True,
|
||||
),
|
||||
"bus": RadioSelect(),
|
||||
"team": RadioSelect(),
|
||||
}
|
||||
|
||||
|
||||
@ -154,7 +174,7 @@ class WEIMembership1AForm(WEIMembershipForm):
|
||||
"""
|
||||
Used to confirm registrations of first year members without choosing a bus now.
|
||||
"""
|
||||
caution_check = None
|
||||
deposit_check = None
|
||||
roles = None
|
||||
|
||||
def clean(self):
|
||||
|
@ -2,11 +2,11 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
|
||||
from .wei2024 import WEISurvey2024
|
||||
from .wei2025 import WEISurvey2025
|
||||
|
||||
|
||||
__all__ = [
|
||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||
]
|
||||
|
||||
CurrentSurvey = WEISurvey2024
|
||||
CurrentSurvey = WEISurvey2025
|
||||
|
@ -121,6 +121,13 @@ class WEISurveyAlgorithm:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def get_bus_information_form(cls):
|
||||
"""
|
||||
The class of the form to update the bus information.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class WEISurvey:
|
||||
"""
|
||||
|
347
apps/wei/forms/surveys/wei2025.py
Normal file
347
apps/wei/forms/surveys/wei2025.py
Normal file
@ -0,0 +1,347 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import time
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from random import Random
|
||||
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
|
||||
from ...models import WEIMembership, Bus
|
||||
|
||||
WORDS = [
|
||||
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
|
||||
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
|
||||
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
|
||||
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
|
||||
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
|
||||
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
|
||||
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
|
||||
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
|
||||
]
|
||||
|
||||
|
||||
class WEISurveyForm2025(forms.Form):
|
||||
"""
|
||||
Survey form for the year 2025.
|
||||
Members choose 20 words, from which we calculate the best associated bus.
|
||||
"""
|
||||
|
||||
word = forms.ChoiceField(
|
||||
label=_("Choose a word:"),
|
||||
widget=forms.RadioSelect(),
|
||||
)
|
||||
|
||||
def set_registration(self, registration):
|
||||
"""
|
||||
Filter the bus selector with the buses of the current WEI.
|
||||
"""
|
||||
information = WEISurveyInformation2025(registration)
|
||||
if not information.seed:
|
||||
information.seed = int(1000 * time.time())
|
||||
information.save(registration)
|
||||
registration._force_save = True
|
||||
registration.save()
|
||||
|
||||
if self.data:
|
||||
self.fields["word"].choices = [(w, w) for w in WORDS]
|
||||
if self.is_valid():
|
||||
return
|
||||
|
||||
rng = Random((information.step + 1) * information.seed)
|
||||
|
||||
buses = WEISurveyAlgorithm2025.get_buses()
|
||||
informations = {bus: WEIBusInformation2025(bus) for bus in buses}
|
||||
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
|
||||
if scores:
|
||||
average_score = sum(scores) / len(scores)
|
||||
else:
|
||||
average_score = 0
|
||||
|
||||
preferred_words = {bus: [word for word in WORDS
|
||||
if informations[bus].scores[word] >= average_score]
|
||||
for bus in buses}
|
||||
|
||||
# Correction : proposer plusieurs mots différents à chaque étape
|
||||
n_choices = 4 # Nombre de mots à proposer à chaque étape
|
||||
all_preferred_words = set()
|
||||
for bus_words in preferred_words.values():
|
||||
all_preferred_words.update(bus_words)
|
||||
all_preferred_words = list(all_preferred_words)
|
||||
rng.shuffle(all_preferred_words)
|
||||
words = all_preferred_words[:n_choices]
|
||||
self.fields["word"].choices = [(w, w) for w in words]
|
||||
|
||||
|
||||
class WEIBusInformation2025(WEIBusInformation):
|
||||
"""
|
||||
For each word, the bus has a score
|
||||
"""
|
||||
scores: dict
|
||||
|
||||
def __init__(self, bus):
|
||||
self.scores = {}
|
||||
for word in WORDS:
|
||||
self.scores[word] = 0
|
||||
super().__init__(bus)
|
||||
|
||||
|
||||
class BusInformationForm2025(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Bus
|
||||
fields = ['information_json']
|
||||
widgets = {}
|
||||
|
||||
def __init__(self, *args, words=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
initial_scores = {}
|
||||
if self.instance and self.instance.information_json:
|
||||
try:
|
||||
info = json.loads(self.instance.information_json)
|
||||
initial_scores = info.get("scores", {})
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
initial_scores = {}
|
||||
if words is None:
|
||||
words = WORDS
|
||||
self.words = words
|
||||
|
||||
choices = [(i, str(i)) for i in range(6)] # [(0, '0'), (1, '1'), ..., (5, '5')]
|
||||
for word in words:
|
||||
self.fields[word] = forms.TypedChoiceField(
|
||||
label=word,
|
||||
choices=choices,
|
||||
coerce=int,
|
||||
initial=initial_scores.get(word, 0),
|
||||
required=True,
|
||||
widget=forms.RadioSelect,
|
||||
help_text=_("Rate between 0 and 5."),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
scores = {}
|
||||
for word in self.words:
|
||||
value = cleaned_data.get(word)
|
||||
if value is not None:
|
||||
scores[word] = value
|
||||
# On encode en JSON
|
||||
cleaned_data['information_json'] = json.dumps({"scores": scores})
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class WEISurveyInformation2025(WEISurveyInformation):
|
||||
"""
|
||||
We store the id of the selected bus. We store only the name, but is not used in the selection:
|
||||
that's only for humans that try to read data.
|
||||
"""
|
||||
# Random seed that is stored at the first time to ensure that words are generated only once
|
||||
seed = 0
|
||||
step = 0
|
||||
|
||||
def __init__(self, registration):
|
||||
for i in range(1, 21):
|
||||
setattr(self, "word" + str(i), None)
|
||||
super().__init__(registration)
|
||||
|
||||
|
||||
class WEISurvey2025(WEISurvey):
|
||||
"""
|
||||
Survey for the year 2025.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_year(cls):
|
||||
return 2025
|
||||
|
||||
@classmethod
|
||||
def get_survey_information_class(cls):
|
||||
return WEISurveyInformation2025
|
||||
|
||||
def get_form_class(self):
|
||||
return WEISurveyForm2025
|
||||
|
||||
def update_form(self, form):
|
||||
"""
|
||||
Filter the bus selector with the buses of the WEI.
|
||||
"""
|
||||
form.set_registration(self.registration)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
word = form.cleaned_data["word"]
|
||||
self.information.step += 1
|
||||
setattr(self.information, "word" + str(self.information.step), word)
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def get_algorithm_class(cls):
|
||||
return WEISurveyAlgorithm2025
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""
|
||||
The survey is complete once the bus is chosen.
|
||||
"""
|
||||
return self.information.step == 20
|
||||
|
||||
@classmethod
|
||||
@lru_cache()
|
||||
def word_mean(cls, word):
|
||||
"""
|
||||
Calculate the mid-score given by all buses.
|
||||
"""
|
||||
buses = cls.get_algorithm_class().get_buses()
|
||||
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
|
||||
|
||||
@lru_cache()
|
||||
def score(self, bus):
|
||||
if not self.is_complete():
|
||||
raise ValueError("Survey is not ended, can't calculate score")
|
||||
|
||||
bus_info = self.get_algorithm_class().get_bus_information(bus)
|
||||
# Score is the given score by the bus subtracted to the mid-score of the buses.
|
||||
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
|
||||
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
|
||||
return s
|
||||
|
||||
@lru_cache()
|
||||
def scores_per_bus(self):
|
||||
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
|
||||
|
||||
@lru_cache()
|
||||
def ordered_buses(self):
|
||||
values = list(self.scores_per_bus().items())
|
||||
values.sort(key=lambda item: -item[1])
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls):
|
||||
cls.word_mean.cache_clear()
|
||||
return super().clear_cache()
|
||||
|
||||
|
||||
class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
|
||||
"""
|
||||
The algorithm class for the year 2025.
|
||||
We use Gale-Shapley algorithm to attribute 1y students into buses.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_survey_class(cls):
|
||||
return WEISurvey2025
|
||||
|
||||
@classmethod
|
||||
def get_bus_information_class(cls):
|
||||
return WEIBusInformation2025
|
||||
|
||||
@classmethod
|
||||
def get_bus_information_form(cls):
|
||||
return BusInformationForm2025
|
||||
|
||||
def run_algorithm(self, display_tqdm=False):
|
||||
"""
|
||||
Gale-Shapley algorithm implementation.
|
||||
We modify it to allow buses to have multiple "weddings".
|
||||
"""
|
||||
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
|
||||
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
|
||||
# Don't manage hardcoded people
|
||||
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
|
||||
|
||||
# Reset previous algorithm run
|
||||
for survey in surveys:
|
||||
survey.free()
|
||||
survey.save()
|
||||
|
||||
non_men = [s for s in surveys if s.registration.gender != 'male']
|
||||
men = [s for s in surveys if s.registration.gender == 'male']
|
||||
|
||||
quotas = {}
|
||||
registrations = self.get_registrations()
|
||||
non_men_total = registrations.filter(~Q(gender='male')).count()
|
||||
for bus in self.get_buses():
|
||||
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||
# Remove hardcoded people
|
||||
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||
registration__information_json__icontains="hardcoded").count()
|
||||
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
|
||||
|
||||
tqdm_obj = None
|
||||
if display_tqdm:
|
||||
from tqdm import tqdm
|
||||
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
|
||||
|
||||
# Repartition for non men people first
|
||||
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
|
||||
|
||||
quotas = {}
|
||||
for bus in self.get_buses():
|
||||
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
|
||||
# Remove hardcoded people
|
||||
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||
registration__information_json__icontains="hardcoded").count()
|
||||
quotas[bus] = free_seats
|
||||
|
||||
if display_tqdm:
|
||||
tqdm_obj.close()
|
||||
|
||||
from tqdm import tqdm
|
||||
tqdm_obj = tqdm(total=len(men), desc="Hommes")
|
||||
|
||||
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
|
||||
|
||||
if display_tqdm:
|
||||
tqdm_obj.close()
|
||||
|
||||
# Clear cache information after running algorithm
|
||||
WEISurvey2025.clear_cache()
|
||||
|
||||
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
|
||||
free_surveys = surveys.copy() # Remaining surveys
|
||||
while free_surveys: # Some students are not affected
|
||||
survey = free_surveys[0]
|
||||
buses = survey.ordered_buses() # Preferences of the student
|
||||
for bus, current_score in buses:
|
||||
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
|
||||
# Selected bus has free places. Put student in the bus
|
||||
survey.select_bus(bus)
|
||||
survey.save()
|
||||
free_surveys.remove(survey)
|
||||
break
|
||||
else:
|
||||
# Current bus has not enough places. Remove the least preferred student from the bus if existing
|
||||
least_preferred_survey = None
|
||||
least_score = -1
|
||||
# Find the least student in the bus that has a lower score than the current student
|
||||
for survey2 in surveys:
|
||||
if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
|
||||
continue
|
||||
score2 = survey2.score(bus)
|
||||
if current_score <= score2: # Ignore better students
|
||||
continue
|
||||
if least_preferred_survey is None or score2 < least_score:
|
||||
least_preferred_survey = survey2
|
||||
least_score = score2
|
||||
|
||||
if least_preferred_survey is not None:
|
||||
# Remove the least student from the bus and put the current student in.
|
||||
# If it does not exist, choose the next bus.
|
||||
least_preferred_survey.free()
|
||||
least_preferred_survey.save()
|
||||
free_surveys.append(least_preferred_survey)
|
||||
survey.select_bus(bus)
|
||||
survey.save()
|
||||
free_surveys.remove(survey)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"User {survey.registration.user} has no free seat")
|
||||
|
||||
if tqdm_obj is not None:
|
||||
tqdm_obj.n = len(surveys) - len(free_surveys)
|
||||
tqdm_obj.refresh()
|
18
apps/wei/migrations/0011_alter_weiclub_year.py
Normal file
18
apps/wei/migrations/0011_alter_weiclub_year.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.21 on 2025-05-25 12:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wei', '0010_remove_weiregistration_specific_diet'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='weiclub',
|
||||
name='year',
|
||||
field=models.PositiveIntegerField(default=2025, unique=True, verbose_name='year'),
|
||||
),
|
||||
]
|
20
apps/wei/migrations/0012_bus_club.py
Normal file
20
apps/wei/migrations/0012_bus_club.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.21 on 2025-05-29 16:16
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0014_create_bda'),
|
||||
('wei', '0011_alter_weiclub_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bus',
|
||||
name='club',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bus', to='member.club', verbose_name='club'),
|
||||
),
|
||||
]
|
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.21 on 2025-06-01 21:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wei', '0012_bus_club'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='weiclub',
|
||||
name='caution_amount',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='caution amount'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='weiregistration',
|
||||
name='caution_type',
|
||||
field=models.CharField(choices=[('check', 'Check'), ('note', 'Note transaction')], default='check', max_length=16, verbose_name='caution type'),
|
||||
),
|
||||
]
|
18
apps/wei/migrations/0014_weiclub_fee_soge_credit.py
Normal file
18
apps/wei/migrations/0014_weiclub_fee_soge_credit.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.23 on 2025-07-15 14:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wei', '0013_weiclub_caution_amount_weiregistration_caution_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='weiclub',
|
||||
name='fee_soge_credit',
|
||||
field=models.PositiveIntegerField(default=2000, verbose_name='fee soge credit'),
|
||||
),
|
||||
]
|
@ -0,0 +1,40 @@
|
||||
# Generated by Django 4.2.23 on 2025-07-15 16:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wei', '0014_weiclub_fee_soge_credit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='weiclub',
|
||||
name='caution_amount',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='weiregistration',
|
||||
name='caution_check',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='weiregistration',
|
||||
name='caution_type',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='weiclub',
|
||||
name='deposit_amount',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='deposit amount'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='weiregistration',
|
||||
name='deposit_check',
|
||||
field=models.BooleanField(default=False, verbose_name='Deposit check given'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='weiregistration',
|
||||
name='deposit_type',
|
||||
field=models.CharField(choices=[('check', 'Check'), ('note', 'Note transaction')], default='check', max_length=16, verbose_name='deposit type'),
|
||||
),
|
||||
]
|
@ -33,6 +33,16 @@ class WEIClub(Club):
|
||||
verbose_name=_("date end"),
|
||||
)
|
||||
|
||||
deposit_amount = models.PositiveIntegerField(
|
||||
verbose_name=_("deposit amount"),
|
||||
default=0,
|
||||
)
|
||||
|
||||
fee_soge_credit = models.PositiveIntegerField(
|
||||
verbose_name=_("membership fee (soge credit)"),
|
||||
default=2000,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("WEI")
|
||||
verbose_name_plural = _("WEI")
|
||||
@ -72,6 +82,15 @@ class Bus(models.Model):
|
||||
default=50,
|
||||
)
|
||||
|
||||
club = models.OneToOneField(
|
||||
Club,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="bus",
|
||||
verbose_name=_("club"),
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
@ -183,9 +202,19 @@ class WEIRegistration(models.Model):
|
||||
verbose_name=_("Credit from Société générale"),
|
||||
)
|
||||
|
||||
caution_check = models.BooleanField(
|
||||
deposit_check = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Caution check given")
|
||||
verbose_name=_("Deposit check given")
|
||||
)
|
||||
|
||||
deposit_type = models.CharField(
|
||||
max_length=16,
|
||||
choices=(
|
||||
('check', _("Check")),
|
||||
('note', _("Note transaction")),
|
||||
),
|
||||
default='check',
|
||||
verbose_name=_("deposit type"),
|
||||
)
|
||||
|
||||
birth_date = models.DateField(
|
||||
@ -295,7 +324,8 @@ class WEIRegistration(models.Model):
|
||||
date_start__gte=bde.membership_start,
|
||||
).exists()
|
||||
|
||||
fee = self.wei.membership_fee_paid if self.user.profile.paid \
|
||||
fee = self.wei.fee_soge_credit if self.soge_credit \
|
||||
else self.wei.membership_fee_paid if self.user.profile.paid \
|
||||
else self.wei.membership_fee_unpaid
|
||||
if not kfet_member:
|
||||
fee += kfet.membership_fee_paid if self.user.profile.paid \
|
||||
|
@ -98,7 +98,7 @@ class WEIRegistrationTable(tables.Table):
|
||||
if not hasperm:
|
||||
return format_html("<span class='no-perm'></span>")
|
||||
|
||||
url = reverse_lazy('wei:validate_registration', args=(record.pk,))
|
||||
url = reverse_lazy('wei:wei_update_registration', args=(record.pk,)) + '?validate=true'
|
||||
text = _('Validate')
|
||||
if record.fee > record.user.note.balance and not record.soge_credit:
|
||||
btn_class = 'btn-secondary'
|
||||
@ -123,7 +123,7 @@ class WEIRegistrationTable(tables.Table):
|
||||
}
|
||||
model = WEIRegistration
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'caution_check',
|
||||
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'deposit_check',
|
||||
'edit', 'validate', 'delete',)
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
@ -163,7 +163,7 @@ class WEIMembershipTable(tables.Table):
|
||||
model = WEIMembership
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('user', 'user__last_name', 'user__first_name', 'registration__gender', 'user__profile__department',
|
||||
'year', 'bus', 'team', 'registration__caution_check', )
|
||||
'year', 'bus', 'team', 'registration__deposit_check', )
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'id': lambda record: "row-" + str(record.pk),
|
||||
|
@ -40,22 +40,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<dt class="col-xl-6">{% trans 'membership fee'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}</dd>
|
||||
{% else %}
|
||||
{% with bde_kfet_fee=club.parent_club.membership_fee_paid|add:club.parent_club.parent_club.membership_fee_paid %}
|
||||
<dt class="col-xl-6">{% trans 'WEI fee (paid students)'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_paid|add:bde_kfet_fee|pretty_money }}
|
||||
<i class="fa fa-question-circle"
|
||||
title="{% trans "The BDE membership is included in the WEI registration." %}"></i></dd>
|
||||
{% endwith %}
|
||||
|
||||
{% with bde_kfet_fee=club.parent_club.membership_fee_unpaid|add:club.parent_club.parent_club.membership_fee_unpaid %}
|
||||
<dt class="col-xl-6">{% trans 'WEI fee (paid students)'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_paid|pretty_money }}
|
||||
|
||||
<dt class="col-xl-6">{% trans 'WEI fee (unpaid students)'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.membership_fee_unpaid|add:bde_kfet_fee|pretty_money }}
|
||||
<i class="fa fa-question-circle"
|
||||
title="{% trans "The BDE membership is included in the WEI registration." %}"></i></dd>
|
||||
{% endwith %}
|
||||
<dd class="col-xl-6">{{ club.membership_fee_unpaid|pretty_money }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if club.deposit_amount > 0 %}
|
||||
<dt class="col-xl-6">{% trans 'Deposit amount'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.deposit_amount|pretty_money }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if "note.view_note"|has_perm:club.note %}
|
||||
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ club.note.balance | pretty_money }}</dd>
|
||||
|
@ -16,8 +16,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-center">
|
||||
{% if object.club %}
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'member:club_detail' pk=object.club.pk %}"
|
||||
data-turbolinks="false">{% trans "View club" %}</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=object.pk %}"
|
||||
data-turbolinks="false">{% trans "Edit" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus_info' pk=object.pk %}"
|
||||
data-turbolinks="false">{% trans "Edit information" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=object.pk %}"
|
||||
data-turbolinks="false">{% trans "Add team" %}</a>
|
||||
</div>
|
||||
|
@ -18,6 +18,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<div class="card-footer text-center">
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:update_bus' pk=bus.pk %}"
|
||||
data-turbolinks="false">{% trans "Edit" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:manage_bus' pk=bus.pk %}"
|
||||
data-turbolinks="false">{% trans "View" %}</a>
|
||||
<a class="btn btn-primary btn-sm my-1" href="{% url 'wei:add_team' pk=bus.pk %}"
|
||||
data-turbolinks="false">{% trans "Add team" %}</a>
|
||||
</div>
|
||||
|
@ -13,9 +13,17 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.jscolor && jscolor.install) {
|
||||
jscolor.install();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -95,9 +95,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_validate_1a %}
|
||||
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
||||
{% endif %}
|
||||
{% if can_validate_1a %}
|
||||
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
|
@ -95,8 +95,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<dd class="col-xl-6"><em>{% trans "The algorithm didn't run." %}</em></dd>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<dt class="col-xl-6">{% trans 'caution check given'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ registration.caution_check|yesno }}</dd>
|
||||
<dt class="col-xl-6">{% trans 'Deposit check given'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ registration.deposit_check|yesno }}</dd>
|
||||
|
||||
{% with information=registration.information %}
|
||||
<dt class="col-xl-6">{% trans 'preferred bus'|capfirst %}</dt>
|
||||
@ -137,31 +137,37 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% if registration.soge_credit %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
The WEI will be paid by Société générale. The membership will be created even if the bank didn't pay the BDE yet.
|
||||
The WEI will partially be paid by Société générale. The membership will be created even if the bank didn't pay the BDE yet.
|
||||
The membership transaction will be created but will be invalid. You will have to validate it once the bank
|
||||
validated the creation of the account, or to change the payment method.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if registration.user.note.balance < fee %}
|
||||
<div class="alert alert-danger">
|
||||
{% with pretty_fee=fee|pretty_money %}
|
||||
{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
|
||||
The note don't have enough money ({{ balance }}, {{ pretty_fee }} required).
|
||||
The registration may fail if you don't credit the note now.
|
||||
{% endblocktrans %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed with pretty_fee=fee|pretty_money %}
|
||||
The note has enough money ({{ pretty_fee }} required), the registration is possible.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="alert {% if registration.user.note.balance < fee %}alert-danger{% else %}alert-success{% endif %}">
|
||||
<h5>{% trans "Required payments:" %}</h5>
|
||||
<ul>
|
||||
<li>{% blocktrans trimmed with amount=fee|pretty_money %}
|
||||
Membership fees: {{ amount }}
|
||||
{% endblocktrans %}</li>
|
||||
{% if registration.deposit_type == 'note' %}
|
||||
<li>{% blocktrans trimmed with amount=club.deposit_amount|pretty_money %}
|
||||
Deposit (by Note transaction): {{ amount }}
|
||||
{% endblocktrans %}</li>
|
||||
{% else %}
|
||||
<li>{% blocktrans trimmed with amount=club.deposit_amount|pretty_money %}
|
||||
Deposit (by check): {{ amount }}
|
||||
{% endblocktrans %}</li>
|
||||
{% endif %}
|
||||
<li><strong>{% blocktrans trimmed with total=total_needed|pretty_money %}
|
||||
Total needed: {{ total }}
|
||||
{% endblocktrans %}</strong></li>
|
||||
</ul>
|
||||
<p>{% blocktrans trimmed with balance=registration.user.note.balance|pretty_money %}
|
||||
Current balance: {{ balance }}
|
||||
{% endblocktrans %}</p>
|
||||
</div>
|
||||
|
||||
{% if not registration.caution_check and not registration.first_year %}
|
||||
{% if not registration.deposit_check and not registration.first_year and registration.caution_type == 'check' %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The user didn't give her/his caution check." %}
|
||||
</div>
|
||||
@ -200,4 +206,27 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
function refreshTeams() {
|
||||
let buses = [];
|
||||
$("input[name='bus']:checked").each(function (ignored) {
|
||||
buses.push($(this).parent().text().trim());
|
||||
});
|
||||
console.log(buses);
|
||||
$("input[name='team']").each(function () {
|
||||
let label = $(this).parent();
|
||||
$(this).parent().addClass('d-none');
|
||||
buses.forEach(function (bus) {
|
||||
if (label.text().includes(bus))
|
||||
label.removeClass('d-none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$("input[name='bus']").change(refreshTeams);
|
||||
|
||||
refreshTeams();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -6,8 +6,6 @@ from datetime import date, timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from note.models import NoteUser
|
||||
|
||||
from ..forms.surveys.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024
|
||||
from ..models import Bus, WEIClub, WEIRegistration
|
||||
@ -129,44 +127,3 @@ class TestWEIAlgorithm(TestCase):
|
||||
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
|
||||
|
||||
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
||||
|
||||
def test_register_1a(self):
|
||||
"""
|
||||
Test register a first year member to the WEI and complete the survey
|
||||
"""
|
||||
response = self.client.get(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user = User.objects.create(username="toto", email="toto@example.com")
|
||||
NoteUser.objects.create(user=user)
|
||||
response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict(
|
||||
user=user.id,
|
||||
soge_credit=True,
|
||||
birth_date=date(2000, 1, 1),
|
||||
gender='nonbinary',
|
||||
clothing_cut='female',
|
||||
clothing_size='XS',
|
||||
health_issues='I am a bot',
|
||||
emergency_contact_name='NoteKfet2020',
|
||||
emergency_contact_phone='+33123456789',
|
||||
))
|
||||
qs = WEIRegistration.objects.filter(user_id=user.id)
|
||||
self.assertTrue(qs.exists())
|
||||
registration = qs.get()
|
||||
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302, 200)
|
||||
for question in WORDS:
|
||||
# Fill 1A Survey, 10 pages
|
||||
# be careful if questionnary form change (number of page, type of answer...)
|
||||
response = self.client.post(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), {
|
||||
question: "1"
|
||||
})
|
||||
registration.refresh_from_db()
|
||||
survey = WEISurvey2024(registration)
|
||||
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302,
|
||||
302 if survey.is_complete() else 200)
|
||||
self.assertIsNotNone(getattr(survey.information, question), "Survey page " + question + " failed")
|
||||
survey = WEISurvey2024(registration)
|
||||
self.assertTrue(survey.is_complete())
|
||||
survey.select_bus(self.buses[0])
|
||||
survey.save()
|
||||
self.assertIsNotNone(survey.information.get_selected_bus())
|
||||
|
111
apps/wei/tests/test_wei_algorithm_2025.py
Normal file
111
apps/wei/tests/test_wei_algorithm_2025.py
Normal file
@ -0,0 +1,111 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
|
||||
from ..forms.surveys.wei2025 import WEIBusInformation2025, WEISurvey2025, WORDS, WEISurveyInformation2025
|
||||
from ..models import Bus, WEIClub, WEIRegistration
|
||||
|
||||
|
||||
class TestWEIAlgorithm(TestCase):
|
||||
"""
|
||||
Run some tests to ensure that the WEI algorithm is working well.
|
||||
"""
|
||||
fixtures = ('initial',)
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create some test data, with one WEI and 10 buses with random score attributions.
|
||||
"""
|
||||
self.wei = WEIClub.objects.create(
|
||||
name="WEI 2025",
|
||||
email="wei2025@example.com",
|
||||
date_start='2025-09-12',
|
||||
date_end='2025-09-14',
|
||||
year=2025,
|
||||
membership_start='2025-06-01'
|
||||
)
|
||||
|
||||
self.buses = []
|
||||
for i in range(10):
|
||||
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
|
||||
self.buses.append(bus)
|
||||
information = WEIBusInformation2025(bus)
|
||||
for word in WORDS:
|
||||
information.scores[word] = random.randint(0, 101)
|
||||
information.save()
|
||||
bus.save()
|
||||
|
||||
def test_survey_algorithm_small(self):
|
||||
"""
|
||||
There are only a few people in each bus, ensure that each person has its best bus
|
||||
"""
|
||||
# Add a few users
|
||||
for i in range(10):
|
||||
user = User.objects.create(username=f"user{i}")
|
||||
registration = WEIRegistration.objects.create(
|
||||
user=user,
|
||||
wei=self.wei,
|
||||
first_year=True,
|
||||
birth_date='2000-01-01',
|
||||
)
|
||||
information = WEISurveyInformation2025(registration)
|
||||
for j in range(1, 21):
|
||||
setattr(information, f'word{j}', random.choice(WORDS))
|
||||
information.step = 20
|
||||
information.save(registration)
|
||||
registration.save()
|
||||
|
||||
# Run algorithm
|
||||
WEISurvey2025.get_algorithm_class()().run_algorithm()
|
||||
|
||||
# Ensure that everyone has its first choice
|
||||
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||
survey = WEISurvey2025(r)
|
||||
preferred_bus = survey.ordered_buses()[0][0]
|
||||
chosen_bus = survey.information.get_selected_bus()
|
||||
self.assertEqual(preferred_bus, chosen_bus)
|
||||
|
||||
def test_survey_algorithm_full(self):
|
||||
"""
|
||||
Buses are full of first year people, ensure that they are happy
|
||||
"""
|
||||
# Add a lot of users
|
||||
for i in range(95):
|
||||
user = User.objects.create(username=f"user{i}")
|
||||
registration = WEIRegistration.objects.create(
|
||||
user=user,
|
||||
wei=self.wei,
|
||||
first_year=True,
|
||||
birth_date='2000-01-01',
|
||||
)
|
||||
information = WEISurveyInformation2025(registration)
|
||||
for j in range(1, 21):
|
||||
setattr(information, f'word{j}', random.choice(WORDS))
|
||||
information.step = 20
|
||||
information.save(registration)
|
||||
registration.save()
|
||||
|
||||
# Run algorithm
|
||||
WEISurvey2025.get_algorithm_class()().run_algorithm()
|
||||
|
||||
penalty = 0
|
||||
# Ensure that everyone seems to be happy
|
||||
# We attribute a penalty for each user that didn't have its first choice
|
||||
# The penalty is the square of the distance between the score of the preferred bus
|
||||
# and the score of the attributed bus
|
||||
# We consider it acceptable if the mean of this distance is lower than 5 %
|
||||
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||
survey = WEISurvey2025(r)
|
||||
chosen_bus = survey.information.get_selected_bus()
|
||||
buses = survey.ordered_buses()
|
||||
score = min(v for bus, v in buses if bus == chosen_bus)
|
||||
max_score = buses[0][1]
|
||||
penalty += (max_score - score) ** 2
|
||||
|
||||
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
|
||||
|
||||
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
@ -101,7 +101,7 @@ class TestWEIRegistration(TestCase):
|
||||
user_id=self.user.id,
|
||||
wei_id=self.wei.id,
|
||||
soge_credit=True,
|
||||
caution_check=True,
|
||||
deposit_check=True,
|
||||
birth_date=date(2000, 1, 1),
|
||||
gender="nonbinary",
|
||||
clothing_cut="male",
|
||||
@ -121,11 +121,13 @@ class TestWEIRegistration(TestCase):
|
||||
email="gc.wei@example.com",
|
||||
membership_fee_paid=12500,
|
||||
membership_fee_unpaid=5500,
|
||||
fee_soge_credit=2000,
|
||||
membership_start=str(self.year + 1) + "-08-01",
|
||||
membership_end=str(self.year + 1) + "-09-30",
|
||||
year=self.year + 1,
|
||||
date_start=str(self.year + 1) + "-09-01",
|
||||
date_end=str(self.year + 1) + "-09-03",
|
||||
deposit_amount=12000,
|
||||
))
|
||||
qs = WEIClub.objects.filter(name="Create WEI Test", year=self.year + 1)
|
||||
self.assertTrue(qs.exists())
|
||||
@ -156,10 +158,12 @@ class TestWEIRegistration(TestCase):
|
||||
email="wei-updated@example.com",
|
||||
membership_fee_paid=0,
|
||||
membership_fee_unpaid=0,
|
||||
fee_soge_credit=0,
|
||||
membership_start="2000-08-01",
|
||||
membership_end="2000-09-30",
|
||||
date_start="2000-09-01",
|
||||
date_end="2000-09-03",
|
||||
deposit_amount=12000,
|
||||
))
|
||||
qs = WEIClub.objects.filter(name="Update WEI Test", id=self.wei.id)
|
||||
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.wei.pk)), 302, 200)
|
||||
@ -318,6 +322,7 @@ class TestWEIRegistration(TestCase):
|
||||
bus=[],
|
||||
team=[],
|
||||
roles=[],
|
||||
deposit_type='check'
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.context["membership_form"].is_valid())
|
||||
@ -334,7 +339,8 @@ class TestWEIRegistration(TestCase):
|
||||
emergency_contact_phone='+33123456789',
|
||||
bus=[self.bus.id],
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()],
|
||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")).all()],
|
||||
deposit_type='check'
|
||||
))
|
||||
qs = WEIRegistration.objects.filter(user_id=user.id)
|
||||
self.assertTrue(qs.exists())
|
||||
@ -354,6 +360,7 @@ class TestWEIRegistration(TestCase):
|
||||
bus=[self.bus.id],
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(~Q(name="1A")).all()],
|
||||
deposit_type='check'
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue("This user is already registered to this WEI." in str(response.context["form"].errors))
|
||||
@ -506,11 +513,12 @@ class TestWEIRegistration(TestCase):
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||
information_json=self.registration.information_json,
|
||||
deposit_type='check'
|
||||
)
|
||||
)
|
||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, soge_credit=False, clothing_size="M")
|
||||
self.assertTrue(qs.exists())
|
||||
self.assertRedirects(response, reverse("wei:validate_registration", kwargs=dict(pk=qs.get().pk)), 302, 200)
|
||||
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=qs.get().wei.pk)), 302, 200)
|
||||
|
||||
# Check the page when the registration is already validated
|
||||
membership = WEIMembership(
|
||||
@ -560,11 +568,12 @@ class TestWEIRegistration(TestCase):
|
||||
team=[self.team.id],
|
||||
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
|
||||
information_json=self.registration.information_json,
|
||||
deposit_type='check'
|
||||
)
|
||||
)
|
||||
qs = WEIRegistration.objects.filter(user_id=self.user.id, clothing_size="L")
|
||||
self.assertTrue(qs.exists())
|
||||
self.assertRedirects(response, reverse("wei:validate_registration", kwargs=dict(pk=qs.get().pk)), 302, 200)
|
||||
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=qs.get().wei.pk)), 302, 200)
|
||||
|
||||
# Test invalid form
|
||||
response = self.client.post(
|
||||
@ -583,6 +592,7 @@ class TestWEIRegistration(TestCase):
|
||||
team=[],
|
||||
roles=[],
|
||||
information_json=self.registration.information_json,
|
||||
deposit_type='check'
|
||||
)
|
||||
)
|
||||
self.assertFalse(response.context["membership_form"].is_valid())
|
||||
@ -624,7 +634,7 @@ class TestWEIRegistration(TestCase):
|
||||
second_bus = Bus.objects.create(wei=self.wei, name="Second bus")
|
||||
second_team = BusTeam.objects.create(bus=second_bus, name="Second team", color=42)
|
||||
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
|
||||
roles=[WEIRole.objects.get(name="GC WEI").id],
|
||||
roles=[WEIRole.objects.get(name="Adhérent⋅e WEI").id],
|
||||
bus=self.bus.pk,
|
||||
team=second_team.pk,
|
||||
credit_type=4, # Bank transfer
|
||||
@ -632,13 +642,14 @@ class TestWEIRegistration(TestCase):
|
||||
last_name="admin",
|
||||
first_name="admin",
|
||||
bank="Société générale",
|
||||
deposit_check=True,
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.context["form"].is_valid())
|
||||
self.assertTrue("This team doesn't belong to the given bus." in str(response.context["form"].errors))
|
||||
|
||||
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
|
||||
roles=[WEIRole.objects.get(name="GC WEI").id],
|
||||
roles=[WEIRole.objects.get(name="Adhérent⋅e WEI").id],
|
||||
bus=self.bus.pk,
|
||||
team=self.team.pk,
|
||||
credit_type=4, # Bank transfer
|
||||
@ -646,8 +657,10 @@ class TestWEIRegistration(TestCase):
|
||||
last_name="admin",
|
||||
first_name="admin",
|
||||
bank="Société générale",
|
||||
deposit_check=True,
|
||||
))
|
||||
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
|
||||
|
||||
# Check if the membership is successfully created
|
||||
membership = WEIMembership.objects.filter(user_id=self.user.id, club=self.wei)
|
||||
self.assertTrue(membership.exists())
|
||||
@ -667,11 +680,7 @@ class TestWEIRegistration(TestCase):
|
||||
self.assertTrue(soge_credit.exists())
|
||||
soge_credit = soge_credit.get()
|
||||
self.assertTrue(membership.transaction in soge_credit.transactions.all())
|
||||
self.assertTrue(kfet_membership.transaction in soge_credit.transactions.all())
|
||||
self.assertTrue(bde_membership.transaction in soge_credit.transactions.all())
|
||||
self.assertFalse(membership.transaction.valid)
|
||||
self.assertFalse(kfet_membership.transaction.valid)
|
||||
self.assertFalse(bde_membership.transaction.valid)
|
||||
|
||||
# Check that if the WEI is started, we can't update a wei
|
||||
self.wei.date_start = date(2000, 1, 1)
|
||||
@ -767,7 +776,7 @@ class TestDefaultWEISurvey(TestCase):
|
||||
WEISurvey.update_form(None, None)
|
||||
|
||||
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
|
||||
self.assertEqual(CurrentSurvey.get_year(), 2024)
|
||||
self.assertEqual(CurrentSurvey.get_year(), 2025)
|
||||
|
||||
|
||||
class TestWeiAPI(TestAPI):
|
||||
@ -804,7 +813,7 @@ class TestWeiAPI(TestAPI):
|
||||
user_id=self.user.id,
|
||||
wei_id=self.wei.id,
|
||||
soge_credit=True,
|
||||
caution_check=True,
|
||||
deposit_check=True,
|
||||
birth_date=date(2000, 1, 1),
|
||||
gender="nonbinary",
|
||||
clothing_cut="male",
|
||||
|
@ -4,7 +4,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
|
||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
|
||||
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, BusInformationUpdateView, \
|
||||
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
|
||||
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
|
||||
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
|
||||
@ -42,4 +42,5 @@ urlpatterns = [
|
||||
path('detail/<int:pk>/closed/', WEIClosedView.as_view(), name="wei_closed"),
|
||||
path('bus-1A/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
|
||||
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
|
||||
path('update-bus-info/<int:pk>/', BusInformationUpdateView.as_view(), name="update_bus_info"),
|
||||
]
|
||||
|
@ -4,16 +4,18 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import date, timedelta
|
||||
from datetime import date
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, Count
|
||||
from django.db.models.functions.text import Lower
|
||||
from django import forms
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import render_to_string
|
||||
@ -25,7 +27,7 @@ from django.views.generic.edit import BaseFormView, DeleteView
|
||||
from django_tables2 import SingleTableView, MultiTableMixin
|
||||
from api.viewsets import is_regex
|
||||
from member.models import Membership, Club
|
||||
from note.models import Transaction, NoteClub, Alias, SpecialTransaction, NoteSpecial
|
||||
from note.models import Transaction, NoteClub, Alias, SpecialTransaction
|
||||
from note.tables import HistoryTable
|
||||
from note_kfet.settings import BASE_DIR
|
||||
from permission.backends import PermissionBackend
|
||||
@ -33,7 +35,7 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
|
||||
from .forms.registration import WEIChooseBusForm
|
||||
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
|
||||
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \
|
||||
from .forms import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, BusForm, BusTeamForm, WEIMembership1AForm, \
|
||||
WEIMembershipForm, CurrentSurvey
|
||||
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
|
||||
WEIRegistration1ATable, WEIMembershipTable
|
||||
@ -441,6 +443,10 @@ class BusTeamCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_template_names(self):
|
||||
names = super().get_template_names()
|
||||
return names
|
||||
|
||||
|
||||
class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
@ -473,6 +479,10 @@ class BusTeamUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy("wei:manage_bus_team", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_template_names(self):
|
||||
names = super().get_template_names()
|
||||
return names
|
||||
|
||||
|
||||
class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
@ -500,7 +510,7 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
Register a new user to the WEI
|
||||
"""
|
||||
model = WEIRegistration
|
||||
form_class = WEIRegistrationForm
|
||||
form_class = WEIRegistration1AForm
|
||||
extra_context = {"title": _("Register first year student to the WEI")}
|
||||
|
||||
def get_sample_object(self):
|
||||
@ -546,9 +556,19 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.fields["user"].initial = self.request.user
|
||||
del form.fields["first_year"]
|
||||
del form.fields["caution_check"]
|
||||
del form.fields["information_json"]
|
||||
|
||||
# Cacher les champs pendant l'inscription initiale
|
||||
if "first_year" in form.fields:
|
||||
del form.fields["first_year"]
|
||||
if "deposit_check" in form.fields:
|
||||
del form.fields["deposit_check"]
|
||||
if "information_json" in form.fields:
|
||||
del form.fields["information_json"]
|
||||
if "deposit_type" in form.fields:
|
||||
del form.fields["deposit_type"]
|
||||
|
||||
if "soge_credit" in form.fields:
|
||||
form.fields["soge_credit"].help_text = _('Check if you will open a Société Générale account')
|
||||
return form
|
||||
|
||||
@transaction.atomic
|
||||
@ -586,7 +606,7 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
Register an old user to the WEI
|
||||
"""
|
||||
model = WEIRegistration
|
||||
form_class = WEIRegistrationForm
|
||||
form_class = WEIRegistration2AForm
|
||||
extra_context = {"title": _("Register old student to the WEI")}
|
||||
|
||||
def get_sample_object(self):
|
||||
@ -640,13 +660,26 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.fields["user"].initial = self.request.user
|
||||
if "soge_credit" in form.fields:
|
||||
form.fields["soge_credit"].help_text = _('Check if you will open a Société Générale account')
|
||||
|
||||
if "myself" in self.request.path and self.request.user.profile.soge:
|
||||
form.fields["soge_credit"].disabled = True
|
||||
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
|
||||
|
||||
del form.fields["caution_check"]
|
||||
del form.fields["first_year"]
|
||||
del form.fields["information_json"]
|
||||
# Cacher les champs pendant l'inscription initiale
|
||||
if "first_year" in form.fields:
|
||||
del form.fields["first_year"]
|
||||
if "deposit_check" in form.fields:
|
||||
del form.fields["deposit_check"]
|
||||
if "information_json" in form.fields:
|
||||
del form.fields["information_json"]
|
||||
|
||||
# S'assurer que le champ deposit_type est obligatoire
|
||||
if "deposit_type" in form.fields:
|
||||
form.fields["deposit_type"].required = True
|
||||
form.fields["deposit_type"].help_text = _("Choose how you want to pay the deposit")
|
||||
form.fields["deposit_type"].widget = forms.RadioSelect(choices=form.fields["deposit_type"].choices)
|
||||
|
||||
return form
|
||||
|
||||
@ -673,6 +706,9 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]]
|
||||
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
||||
form.instance.information = information
|
||||
|
||||
# Sauvegarder le type de caution
|
||||
form.instance.deposit_type = form.cleaned_data["deposit_type"]
|
||||
form.instance.save()
|
||||
|
||||
if 'treasury' in settings.INSTALLED_APPS:
|
||||
@ -702,11 +738,15 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
# We can't update a registration once the WEI is started and before the membership start date
|
||||
if today >= wei.date_start or today < wei.membership_start:
|
||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||
# Store the validate parameter in the view's state
|
||||
self.should_validate = request.GET.get('validate', False)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["club"] = self.object.wei
|
||||
# Pass the validate parameter to the template
|
||||
context["should_validate"] = self.should_validate
|
||||
|
||||
if self.object.is_validated:
|
||||
membership_form = self.get_membership_form(instance=self.object.membership,
|
||||
@ -738,12 +778,23 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
form = super().get_form(form_class)
|
||||
form.fields["user"].disabled = True
|
||||
# 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 "information_json" in form.fields:
|
||||
del form.fields["information_json"]
|
||||
# Masquer le champ deposit_check pour tout le monde dans le formulaire de modification
|
||||
if "deposit_check" in form.fields:
|
||||
del form.fields["deposit_check"]
|
||||
|
||||
# S'assurer que le champ deposit_type est obligatoire pour les 2A+
|
||||
if not self.object.first_year and "deposit_type" in form.fields:
|
||||
form.fields["deposit_type"].required = True
|
||||
form.fields["deposit_type"].help_text = _("Choose how you want to pay the deposit")
|
||||
form.fields["deposit_type"].widget = forms.RadioSelect(choices=form.fields["deposit_type"].choices)
|
||||
|
||||
return form
|
||||
|
||||
def get_membership_form(self, data=None, instance=None):
|
||||
membership_form = WEIMembershipForm(data if data else None, instance=instance)
|
||||
registration = self.get_object()
|
||||
membership_form = WEIMembershipForm(data if data else None, instance=instance, wei=registration.wei)
|
||||
del membership_form.fields["credit_type"]
|
||||
del membership_form.fields["credit_amount"]
|
||||
del membership_form.fields["first_name"]
|
||||
@ -759,10 +810,30 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
def form_valid(self, form):
|
||||
# If the membership is already validated, then we update the bus and the team (and the roles)
|
||||
if form.instance.is_validated:
|
||||
membership_form = self.get_membership_form(self.request.POST, form.instance.membership)
|
||||
if not membership_form.is_valid():
|
||||
try:
|
||||
membership = form.instance.membership
|
||||
if membership is None:
|
||||
raise ValueError(_("No membership found for this registration"))
|
||||
|
||||
membership_form = self.get_membership_form(self.request.POST, instance=membership)
|
||||
if not membership_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Vérifier que l'utilisateur a la permission de modifier le membership
|
||||
# On vérifie d'abord si l'utilisateur a la permission générale de modification
|
||||
if not self.request.user.has_perm("wei.change_weimembership"):
|
||||
raise PermissionDenied(_("You don't have the permission to update memberships"))
|
||||
|
||||
# On vérifie ensuite les permissions spécifiques pour chaque champ modifié
|
||||
for field_name in membership_form.changed_data:
|
||||
perm = f"wei.change_weimembership_{field_name}"
|
||||
if not self.request.user.has_perm(perm):
|
||||
raise PermissionDenied(_("You don't have the permission to update the field %(field)s") % {'field': field_name})
|
||||
|
||||
membership_form.save()
|
||||
except (WEIMembership.DoesNotExist, ValueError, PermissionDenied) as e:
|
||||
form.add_error(None, str(e))
|
||||
return self.form_invalid(form)
|
||||
membership_form.save()
|
||||
# If it is not validated and if this is an old member, then we update the choices
|
||||
elif not form.instance.first_year and PermissionBackend.check_perm(
|
||||
self.request, "wei.change_weiregistration_information_json", self.object):
|
||||
@ -777,6 +848,10 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
information["preferred_roles_pk"] = [role.pk for role in choose_bus_form.cleaned_data["roles"]]
|
||||
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
|
||||
form.instance.information = information
|
||||
|
||||
# Sauvegarder le type de caution pour les 2A+
|
||||
if "deposit_type" in form.cleaned_data:
|
||||
form.instance.deposit_type = form.cleaned_data["deposit_type"]
|
||||
form.instance.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
@ -787,14 +862,8 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
|
||||
survey = CurrentSurvey(self.object)
|
||||
if not survey.is_complete():
|
||||
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
|
||||
if PermissionBackend.check_perm(self.request, "wei.add_weimembership", WEIMembership(
|
||||
club=self.object.wei,
|
||||
user=self.object.user,
|
||||
date_start=date.today(),
|
||||
date_end=date.today(),
|
||||
fee=0,
|
||||
registration=self.object,
|
||||
)):
|
||||
# On redirige vers la validation uniquement si c'est explicitement demandé (et stocké dans la vue)
|
||||
if self.should_validate and self.request.user.has_perm("wei.add_weimembership"):
|
||||
return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk})
|
||||
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.pk})
|
||||
|
||||
@ -836,18 +905,23 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
extra_context = {"title": _("Validate WEI registration")}
|
||||
|
||||
def get_sample_object(self):
|
||||
"""
|
||||
Return a sample object for permission checking
|
||||
"""
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
return WEIMembership(
|
||||
club=registration.wei,
|
||||
user=registration.user,
|
||||
date_start=date.today(),
|
||||
date_end=date.today() + timedelta(days=1),
|
||||
fee=0,
|
||||
club=registration.wei,
|
||||
date_start=registration.wei.date_start,
|
||||
fee=registration.wei.membership_fee_paid if registration.user.profile.paid else registration.wei.membership_fee_unpaid,
|
||||
# Add any fields needed for proper permission checking
|
||||
registration=registration,
|
||||
)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
wei = WEIRegistration.objects.get(pk=self.kwargs["pk"]).wei
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
|
||||
wei = registration.wei
|
||||
today = date.today()
|
||||
# We can't validate anyone once the WEI is started and before the membership start date
|
||||
if today >= wei.date_start or today < wei.membership_start:
|
||||
@ -878,7 +952,14 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
date_start__gte=bde.membership_start,
|
||||
).exists()
|
||||
|
||||
context["fee"] = registration.fee
|
||||
fee = registration.fee
|
||||
context["fee"] = fee
|
||||
|
||||
# Calculer le montant total nécessaire (frais + caution si transaction)
|
||||
total_needed = fee
|
||||
if registration.deposit_type == 'note':
|
||||
total_needed += registration.wei.deposit_amount
|
||||
context["total_needed"] = total_needed
|
||||
|
||||
form = context["form"]
|
||||
if registration.soge_credit:
|
||||
@ -890,27 +971,41 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
def get_form_class(self):
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
if registration.first_year and 'sleected_bus_pk' not in registration.information:
|
||||
if registration.first_year and 'selected_bus_pk' not in registration.information:
|
||||
return WEIMembership1AForm
|
||||
return WEIMembershipForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
wei = registration.wei
|
||||
kwargs['wei'] = wei
|
||||
return kwargs
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
registration = WEIRegistration.objects.get(pk=self.kwargs["pk"])
|
||||
form.fields["last_name"].initial = registration.user.last_name
|
||||
form.fields["first_name"].initial = registration.user.first_name
|
||||
|
||||
if "caution_check" in form.fields:
|
||||
form.fields["caution_check"].initial = registration.caution_check
|
||||
|
||||
if registration.soge_credit:
|
||||
form.fields["credit_type"].disabled = True
|
||||
form.fields["credit_type"].initial = NoteSpecial.objects.get(special_type="Virement bancaire")
|
||||
form.fields["credit_amount"].disabled = True
|
||||
form.fields["last_name"].disabled = True
|
||||
form.fields["first_name"].disabled = True
|
||||
form.fields["bank"].disabled = True
|
||||
form.fields["bank"].initial = "Société générale"
|
||||
# Ajouter le champ deposit_check uniquement pour les non-première année et le rendre obligatoire
|
||||
if not registration.first_year:
|
||||
if registration.deposit_type == 'check':
|
||||
form.fields["deposit_check"] = forms.BooleanField(
|
||||
required=True,
|
||||
initial=registration.deposit_check,
|
||||
label=_("Deposit check given"),
|
||||
help_text=_("Please make sure the check is given before validating the registration")
|
||||
)
|
||||
else:
|
||||
form.fields["deposit_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.deposit_amount / 100
|
||||
}
|
||||
)
|
||||
|
||||
if 'bus' in form.fields:
|
||||
# For 2A+ and hardcoded 1A
|
||||
@ -944,8 +1039,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
club = registration.wei
|
||||
user = registration.user
|
||||
|
||||
if "caution_check" in form.data:
|
||||
registration.caution_check = form.data["caution_check"] == "on"
|
||||
if "deposit_check" in form.data:
|
||||
registration.deposit_check = form.data["deposit_check"] == "on"
|
||||
registration.save()
|
||||
membership = form.instance
|
||||
membership.user = user
|
||||
@ -956,6 +1051,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
membership._force_renew_parent = True
|
||||
|
||||
fee = club.membership_fee_paid if user.profile.paid else club.membership_fee_unpaid
|
||||
if registration.soge_credit:
|
||||
fee = 2000
|
||||
|
||||
kfet = club.parent_club
|
||||
bde = kfet.parent_club
|
||||
@ -982,13 +1079,23 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
first_name = form.cleaned_data["first_name"]
|
||||
bank = form.cleaned_data["bank"]
|
||||
|
||||
if credit_type is None or registration.soge_credit:
|
||||
if credit_type is None:
|
||||
credit_amount = 0
|
||||
|
||||
if not registration.soge_credit and user.note.balance + credit_amount < fee:
|
||||
# Users must have money before registering to the WEI.
|
||||
# Calculer le montant total nécessaire (frais + caution si transaction)
|
||||
total_needed = fee
|
||||
if registration.deposit_type == 'note':
|
||||
total_needed += club.deposit_amount
|
||||
|
||||
# Vérifier que l'utilisateur a assez d'argent pour tout payer
|
||||
if user.note.balance + credit_amount < total_needed:
|
||||
form.add_error('credit_type',
|
||||
_("This user don't have enough money to join this club, and can't have a negative balance."))
|
||||
_("This user doesn't have enough money to join this club and pay the deposit. "
|
||||
"Current balance: %(balance)d€, credit: %(credit)d€, needed: %(needed)d€") % {
|
||||
'balance': user.note.balance,
|
||||
'credit': credit_amount,
|
||||
'needed': total_needed}
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
if credit_amount:
|
||||
@ -1028,6 +1135,18 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
membership.refresh_from_db()
|
||||
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
|
||||
|
||||
# Créer la transaction de caution si nécessaire
|
||||
if registration.deposit_type == 'note':
|
||||
from note.models import Transaction
|
||||
Transaction.objects.create(
|
||||
source=user.note,
|
||||
destination=club.note,
|
||||
quantity=1,
|
||||
amount=club.deposit_amount,
|
||||
reason=_("Deposit %(name)s") % {'name': club.name},
|
||||
valid=True,
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
@ -1247,6 +1366,7 @@ class WEI1AListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableView):
|
||||
def get_queryset(self, filter_permissions=True, **kwargs):
|
||||
qs = super().get_queryset(filter_permissions, **kwargs)
|
||||
qs = qs.filter(first_year=True, membership__isnull=False)
|
||||
qs = qs.filter(wei=self.club)
|
||||
qs = qs.order_by('-membership__bus')
|
||||
return qs
|
||||
|
||||
@ -1289,8 +1409,48 @@ class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
|
||||
if not wei.exists():
|
||||
raise Http404
|
||||
wei = wei.get()
|
||||
qs = WEIRegistration.objects.filter(wei=wei, membership__isnull=False, membership__bus__isnull=True)
|
||||
qs = qs.filter(information_json__contains='selected_bus_pk') # not perfect, but works...
|
||||
if qs.exists():
|
||||
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk, ))
|
||||
return reverse_lazy('wei:wei_1A_list', args=(wei.pk, ))
|
||||
|
||||
# On cherche d'abord les 1A qui ont une inscription validée (membership) mais pas de bus
|
||||
qs = WEIRegistration.objects.filter(
|
||||
wei=wei,
|
||||
first_year=True,
|
||||
membership__isnull=False,
|
||||
membership__bus__isnull=True
|
||||
)
|
||||
|
||||
# Parmi eux, on prend ceux qui ont répondu au questionnaire (ont un bus préféré)
|
||||
qs = qs.filter(information_json__contains='selected_bus_pk')
|
||||
|
||||
if not qs.exists():
|
||||
# Si on ne trouve personne, on affiche un message et on retourne à la liste
|
||||
messages.info(self.request, _("No first year student without a bus found. Either all of them have a bus, or none has filled the survey yet."))
|
||||
return reverse_lazy('wei:wei_1A_list', args=(wei.pk,))
|
||||
|
||||
# On redirige vers la page d'attribution pour le premier étudiant trouvé
|
||||
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk,))
|
||||
|
||||
|
||||
class BusInformationUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
model = Bus
|
||||
|
||||
def get_form_class(self):
|
||||
return CurrentSurvey.get_algorithm_class().get_bus_information_form()
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
wei = self.get_object().wei
|
||||
today = date.today()
|
||||
# We can't update a bus once the WEI is started
|
||||
if today >= wei.date_start:
|
||||
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["club"] = self.object.wei
|
||||
context["information"] = CurrentSurvey.get_algorithm_class().get_bus_information(self.object)
|
||||
self.object.save()
|
||||
return context
|
||||
|
||||
def get_success_url(self):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy("wei:manage_bus", kwargs={"pk": self.object.pk})
|
||||
|
@ -55,6 +55,7 @@ Les adhérent⋅es ont la possibilité d'inviter des ami⋅es. Pour cela, les di
|
||||
* Activité concernée (clé étrangère)
|
||||
* Nom de famille
|
||||
* Prénom
|
||||
* École
|
||||
* Note de la personne ayant invité
|
||||
|
||||
Certaines contraintes s'appliquent :
|
||||
|
@ -19,8 +19,9 @@ Le modèle regroupe :
|
||||
* Propriétaire (doit-être un Club)
|
||||
* Allergènes (ManyToManyField)
|
||||
* date d'expiration
|
||||
* a été mangé (booléen)
|
||||
* fin de vie
|
||||
* est prêt (booléen)
|
||||
* consigne (pour les GCKs)
|
||||
|
||||
BasicFood
|
||||
~~~~~~~~~
|
||||
@ -40,7 +41,7 @@ Les TransformedFood correspondent aux produits préparés à la Kfet. Ils peuven
|
||||
|
||||
Le modèle regroupe :
|
||||
|
||||
* Durée de consommation (par défaut 3 jours)
|
||||
* Durée de conservation (par défaut 3 jours)
|
||||
* Ingrédients (ManyToManyField vers Food)
|
||||
* Date de création
|
||||
* Champs de Food
|
||||
|
@ -12,6 +12,7 @@ Applications de la Note Kfet 2020
|
||||
../api/index
|
||||
registration
|
||||
logs
|
||||
food
|
||||
treasury
|
||||
wei
|
||||
wrapped
|
||||
@ -66,6 +67,8 @@ Applications facultatives
|
||||
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
|
||||
* `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_
|
||||
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc...
|
||||
* `Food <food>`_ :
|
||||
Gestion de la nourriture dans Kfet pour les clubs.
|
||||
* `Treasury <treasury>`_ :
|
||||
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques...
|
||||
* `WEI <wei>`_ :
|
||||
|
@ -43,6 +43,11 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions
|
||||
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
||||
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
|
||||
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
||||
'PKCE_REQUIRED': False,
|
||||
'OIDC_ENABLED': True,
|
||||
'OIDC_RSA_PRIVATE_KEY':
|
||||
os.getenv('OIDC_RSA_PRIVATE_KEY', '/var/secrets/oidc.key'),
|
||||
'SCOPES': { 'openid': "OpenID Connect scope" },
|
||||
}
|
||||
|
||||
Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
|
||||
@ -57,6 +62,14 @@ On ajoute enfin les routes dans ``urls.py`` :
|
||||
path('o/', include('oauth2_provider.urls', namespace='oauth2_provider'))
|
||||
)
|
||||
|
||||
Enfin pour utiliser OIDC, il faut générer une clé privé que l'on va, par défaut,
|
||||
mettre dans `/var/secrets/oidc.key` :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd /var/secrets/
|
||||
openssl genrsa -out oidc.key 4096
|
||||
|
||||
L'OAuth2 est désormais prêt à être utilisé.
|
||||
|
||||
|
||||
|
@ -183,6 +183,7 @@ Contributeur⋅rices
|
||||
* korenst1
|
||||
* nicomarg
|
||||
* PAC
|
||||
* Quark
|
||||
* ÿnérant
|
||||
|
||||
|
||||
|
@ -227,6 +227,22 @@ En production, ce fichier contient :
|
||||
)
|
||||
|
||||
|
||||
Génération d'une clé privé pour OIDC
|
||||
------------------------------------
|
||||
|
||||
Pour pouvoir proposer le service de connexion Openid Connect (OIDC) par OAuth2, il y a
|
||||
besoin d'une clé privé. Par défaut, elle est cherché dans le fichier `/var/secrets/oidc.key`
|
||||
(sinon, il faut modifier l'emplacement dans les fichiers de configurations).
|
||||
|
||||
Pour générer la clé, il faut aller dans le dossier `/var/secrets` (à créer, si nécessaire) puis
|
||||
utiliser la commande de génération :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd /var/secrets
|
||||
openssl genrsa -out oidc.key 4096
|
||||
|
||||
|
||||
Configuration des tâches récurrentes
|
||||
------------------------------------
|
||||
|
||||
|
@ -136,7 +136,7 @@ de diffusion utiles.
|
||||
Faîtes attention, donc où la sortie est stockée.
|
||||
|
||||
|
||||
Il prend 2 options :
|
||||
Il prend 4 options :
|
||||
|
||||
* ``--type``, qui prend en argument ``members`` (défaut), ``clubs``, ``events``, ``art``,
|
||||
``sport``, qui permet respectivement de sortir la liste des adresses mails des adhérent⋅es
|
||||
@ -149,7 +149,10 @@ Il prend 2 options :
|
||||
pour la ML Adhérents, pour exporter les mails des adhérents au BDE pendant n'importe
|
||||
laquelle des ``n+1`` dernières années.
|
||||
|
||||
Le script sort sur la sortie standard la liste des adresses mails à inscrire.
|
||||
* ``--email``, qui prend en argument une chaine de caractère contenant une adresse email.
|
||||
|
||||
Si aucun email n'est renseigné, le script sort sur la sortie standard la liste des adresses mails à inscrire.
|
||||
Dans le cas contraire, la liste est envoyée à l'adresse passée en argument.
|
||||
|
||||
Attention : il y a parfois certains cas particuliers à prendre en compte, il n'est
|
||||
malheureusement pas aussi simple que de simplement supposer que ces listes sont exhaustives.
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user