From d5e9d09044604d60bca74a6e7fff7b4a8a600007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 22 Dec 2019 21:27:28 +0100 Subject: [PATCH 1/7] Events are configurable This commit mostly reproduces the structure of gestioncof's events, renames some stuff and adds a generic export view. --- .../0003_options_and_extra_fields.py | 199 ++++++++++++++++++ events/models.py | 95 ++++++++- events/tests/test_views.py | 84 +++++++- events/views.py | 43 +++- 4 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 events/migrations/0003_options_and_extra_fields.py diff --git a/events/migrations/0003_options_and_extra_fields.py b/events/migrations/0003_options_and_extra_fields.py new file mode 100644 index 00000000..8e6e624d --- /dev/null +++ b/events/migrations/0003_options_and_extra_fields.py @@ -0,0 +1,199 @@ +# Generated by Django 2.2.8 on 2019-12-22 14:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("events", "0002_event_subscribers"), + ] + + operations = [ + migrations.CreateModel( + name="ExtraField", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + max_length=200, verbose_name="champ d'événement supplémentaire" + ), + ), + ( + "field_type", + models.CharField( + choices=[ + ("shorttext", "texte court (une ligne)"), + ("longtext", "texte long (plusieurs lignes)"), + ], + max_length=9, + verbose_name="type de champ", + ), + ), + ], + ), + migrations.CreateModel( + name="Option", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=200, verbose_name="option d'événement"), + ), + ( + "multi_choices", + models.BooleanField(default=False, verbose_name="choix multiples"), + ), + ], + options={ + "verbose_name": "option d'événement", + "verbose_name_plural": "options d'événement", + }, + ), + migrations.CreateModel( + name="OptionChoice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("choice", models.CharField(max_length=200, verbose_name="choix")), + ( + "option", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices", + to="events.Option", + ), + ), + ], + options={ + "verbose_name": "choix d'option d'événement", + "verbose_name_plural": "choix d'option d'événement", + }, + ), + migrations.CreateModel( + name="Registration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "verbose_name": "inscription à un événement", + "verbose_name_plural": "inscriptions à un événement", + }, + ), + migrations.RemoveField(model_name="event", name="subscribers"), + migrations.AddField( + model_name="event", + name="subscribers", + field=models.ManyToManyField( + through="events.Registration", + to=settings.AUTH_USER_MODEL, + verbose_name="inscrit⋅e⋅s", + ), + ), + migrations.AddField( + model_name="registration", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="events.Event" + ), + ), + migrations.AddField( + model_name="registration", + name="options_choices", + field=models.ManyToManyField(to="events.OptionChoice"), + ), + migrations.AddField( + model_name="registration", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + migrations.AddField( + model_name="option", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="events.Event", + ), + ), + migrations.CreateModel( + name="ExtraFieldContent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", models.TextField(verbose_name="contenu du champ")), + ( + "field", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="events.ExtraField", + ), + ), + ( + "registration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="extra_info", + to="events.Registration", + ), + ), + ], + options={ + "verbose_name": "contenu d'un champ événement supplémentaire", + "verbose_name_plural": "contenus d'un champ événement supplémentaire", + }, + ), + migrations.AddField( + model_name="extrafield", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="extra_fields", + to="events.Event", + ), + ), + ] diff --git a/events/models.py b/events/models.py index b2876301..5838e513 100644 --- a/events/models.py +++ b/events/models.py @@ -16,7 +16,9 @@ class Event(models.Model): ) registration_open = models.BooleanField(_("inscriptions ouvertes"), default=True) old = models.BooleanField(_("archiver (événement fini)"), default=False) - subscribers = models.ManyToManyField(User, verbose_name=_("inscrit⋅e⋅s")) + subscribers = models.ManyToManyField( + User, through="Registration", verbose_name=_("inscrit⋅e⋅s") + ) class Meta: verbose_name = _("événement") @@ -26,8 +28,91 @@ class Event(models.Model): return self.title -# TODO: gérer les options (EventOption & EventOptionChoice de gestioncof) -# par exemple: "option végé au Mega (oui / non)" +class Option(models.Model): + """Event options to be selected by participants at registration. -# TODO: gérer les champs commentaires (EventCommentField & EventCommentChoice) -# par exemple: "champ "allergies / régime particulier" au Mega + The possible choices are instances of `OptionChoice` (see below). A typical example + is when the participants have the choice between different meal types (e.g. vegan / + vegetarian / no pork / with meat). In this case, the "meal type" is an `Option` and + the three alternatives are `OptionChoice`s. + """ + + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="options") + name = models.CharField(_("option d'événement"), max_length=200) + multi_choices = models.BooleanField(_("choix multiples"), default=False) + + class Meta: + verbose_name = _("option d'événement") + verbose_name_plural = _("options d'événement") + + def __str__(self): + return self.name + + +class OptionChoice(models.Model): + """A possible choice for an event option (see Option).""" + + option = models.ForeignKey(Option, on_delete=models.CASCADE, related_name="choices") + choice = models.CharField(_("choix"), max_length=200) + + class Meta: + verbose_name = _("choix d'option d'événement") + verbose_name_plural = _("choix d'option d'événement") + + def __str__(self): + return self.choice + + +class ExtraField(models.Model): + """Extra event field, for event creators. + + Extra text field that can be added by event creators to the event registration form. + Typical examples are "remarks" fields (of type LONGTEXT) or more specific questions + such as "emergency contact". + """ + + LONGTEXT = "longtext" + SHORTTEXT = "shorttext" + + FIELD_TYPE = [ + (SHORTTEXT, _("texte court (une ligne)")), + (LONGTEXT, _("texte long (plusieurs lignes)")), + ] + + event = models.ForeignKey( + Event, on_delete=models.CASCADE, related_name="extra_fields" + ) + name = models.CharField(_("champ d'événement supplémentaire"), max_length=200) + field_type = models.CharField(_("type de champ"), max_length=9, choices=FIELD_TYPE) + + +class ExtraFieldContent(models.Model): + field = models.ForeignKey(ExtraField, on_delete=models.CASCADE) + registration = models.ForeignKey( + "Registration", on_delete=models.CASCADE, related_name="extra_info" + ) + content = models.TextField(_("contenu du champ")) + + class Meta: + verbose_name = _("contenu d'un champ événement supplémentaire") + verbose_name_plural = _("contenus d'un champ événement supplémentaire") + + def __str__(self): + max_length = 50 + if len(self.content) > max_length: + return self.content[: max_length - 1] + "…" + else: + return self.content + + +class Registration(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + options_choices = models.ManyToManyField(OptionChoice) + + class Meta: + verbose_name = _("inscription à un événement") + verbose_name_plural = _("inscriptions à un événement") + + def __str__(self): + return "inscription de {} à {}".format(self.user, self.event) diff --git a/events/tests/test_views.py b/events/tests/test_views.py index 5dc01fbb..d9a978e1 100644 --- a/events/tests/test_views.py +++ b/events/tests/test_views.py @@ -1,3 +1,4 @@ +import csv from unittest import mock from django.contrib.auth import get_user_model @@ -5,7 +6,14 @@ from django.contrib.auth.models import Permission from django.test import Client, TestCase from django.urls import reverse -from events.models import Event +from events.models import ( + Event, + ExtraField, + ExtraFieldContent, + Option, + OptionChoice, + Registration, +) User = get_user_model() @@ -23,7 +31,7 @@ def make_staff_user(name): return user -class CSVExportTest(TestCase): +class MessagePatch: def setUp(self): # Signals handlers on login/logout send messages. # Due to the way the Django' test Client performs login, this raise an @@ -32,11 +40,14 @@ class CSVExportTest(TestCase): patcher_messages.start() self.addCleanup(patcher_messages.stop) + +class CSVExportAccessTest(MessagePatch, TestCase): + def setUp(self): + super().setUp() + self.staff = make_staff_user("staff") self.u1 = make_user("toto") - self.u2 = make_user("titi") self.event = Event.objects.create(title="test_event", location="somewhere") - self.event.subscribers.set([self.u1, self.u2]) self.url = reverse("events:csv-participants", args=[self.event.id]) def test_get(self): @@ -57,3 +68,68 @@ class CSVExportTest(TestCase): client.force_login(self.u1) r = client.get(self.url) self.assertEqual(r.status_code, 403) + + +class CSVExportContentTest(MessagePatch, TestCase): + def setUp(self): + super().setUp() + + self.event = Event.objects.create(title="test_event", location="somewhere") + self.url = reverse("events:csv-participants", args=[self.event.id]) + + self.u1 = User.objects.create_user( + username="toto_foo", first_name="toto", last_name="foo", email="toto@a.b" + ) + self.u2 = User.objects.create_user( + username="titi_bar", first_name="titi", last_name="bar", email="titi@a.b" + ) + self.staff = make_staff_user("staff") + self.client = Client() + self.client.force_login(self.staff) + + def test_simple_event(self): + self.event.subscribers.set([self.u1, self.u2]) + + participants = self.client.get(self.url).content.decode("utf-8") + participants = [ + line for line in csv.reader(participants.split("\n")) if line != [] + ] + self.assertEqual(len(participants), 3) + self.assertEqual(participants[1], ["toto_foo", "toto@a.b", "toto", "foo"]) + self.assertEqual(participants[2], ["titi_bar", "titi@a.b", "titi", "bar"]) + + def test_complex_event(self): + registration = Registration.objects.create(event=self.event, user=self.u1) + # Set up some options + option1 = Option.objects.create( + event=self.event, name="abc", multi_choices=False + ) + option2 = Option.objects.create( + event=self.event, name="def", multi_choices=True + ) + OptionChoice.objects.bulk_create( + [ + OptionChoice(option=option1, choice="a"), + OptionChoice(option=option1, choice="b"), + OptionChoice(option=option1, choice="c"), + OptionChoice(option=option2, choice="d"), + OptionChoice(option=option2, choice="e"), + OptionChoice(option=option2, choice="f"), + ] + ) + registration.options_choices.set( + OptionChoice.objects.filter(choice__in=["d", "f"]) + ) + registration.options_choices.add(OptionChoice.objects.get(choice="a")) + # And an extra field + field = ExtraField.objects.create(event=self.event, name="remarks") + ExtraFieldContent.objects.create( + field=field, registration=registration, content="hello" + ) + + participants = self.client.get(self.url).content.decode("utf-8") + participants = list(csv.reader(participants.split("\n"))) + self.assertEqual( + ["toto_foo", "toto@a.b", "toto", "foo", "a", "d & f", "hello"], + participants[1], + ) diff --git a/events/views.py b/events/views.py index 6f49cdb7..71000ed2 100644 --- a/events/views.py +++ b/events/views.py @@ -5,7 +5,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils.text import slugify -from events.models import Event +from events.models import Event, Registration @login_required @@ -13,13 +13,46 @@ from events.models import Event def participants_csv(request, event_id): event = get_object_or_404(Event, id=event_id) + # Create a CSV response filename = "{}-participants.csv".format(slugify(event.title)) response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="{}"'.format(filename) - writer = csv.writer(response) - writer.writerow(["username", "email", "prénom", "nom de famille"]) - for user in event.subscribers.all(): - writer.writerow([user.username, user.email, user.first_name, user.last_name]) + + # The first line of the file is a header + header = ["username", "email", "prénom", "nom de famille"] + options_names = list(event.options.values_list("name", flat=True).order_by("id")) + header += options_names + extra_fields = list( + event.extra_fields.values_list("name", flat=True).order_by("id") + ) + header += extra_fields + writer.writerow(header) + + # Next, one line by registered user + registrations = Registration.objects.filter(event=event) + for registration in registrations: + user = registration.user + row = [user.username, user.email, user.first_name, user.last_name] + + # Options + options_choices = list( + " & ".join( + registration.options_choices.filter(option__id=id).values_list( + "choice", flat=True + ) + ) + for id in event.options.values_list("id", flat=True).order_by("id") + ) + row += options_choices + # Extra info + extra_info = list( + registration.extra_info.values_list("content", flat=True).order_by( + "field__id" + ) + ) + row += extra_info + + writer.writerow(row) return response From e0fd3db638700d13316b2d48e6e5ac5df8710cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 22 Dec 2019 23:08:27 +0100 Subject: [PATCH 2/7] Make events tests deterministic --- events/tests/test_views.py | 8 +++++++- events/views.py | 11 ++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/events/tests/test_views.py b/events/tests/test_views.py index d9a978e1..ee17128b 100644 --- a/events/tests/test_views.py +++ b/events/tests/test_views.py @@ -129,7 +129,13 @@ class CSVExportContentTest(MessagePatch, TestCase): participants = self.client.get(self.url).content.decode("utf-8") participants = list(csv.reader(participants.split("\n"))) + toto_registration = participants[1] + + # This is not super nice, but it makes the test deterministic. + if toto_registration[5] == "f & d": + toto_registration[5] = "d & f" + self.assertEqual( ["toto_foo", "toto@a.b", "toto", "foo", "a", "d & f", "hello"], - participants[1], + toto_registration, ) diff --git a/events/views.py b/events/views.py index 71000ed2..248c4284 100644 --- a/events/views.py +++ b/events/views.py @@ -36,14 +36,11 @@ def participants_csv(request, event_id): row = [user.username, user.email, user.first_name, user.last_name] # Options - options_choices = list( - " & ".join( - registration.options_choices.filter(option__id=id).values_list( - "choice", flat=True - ) - ) + all_choices = registration.options_choices.values_list("choice", flat=True) + options_choices = [ + " & ".join(all_choices.filter(option__id=id)) for id in event.options.values_list("id", flat=True).order_by("id") - ) + ] row += options_choices # Extra info extra_info = list( From 8778695e951e6efbe5913eefbf1464a24bdc019e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 22 Dec 2019 23:37:20 +0100 Subject: [PATCH 3/7] Add some more documentation in events.models --- events/models.py | 51 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/events/models.py b/events/models.py index 5838e513..e334784e 100644 --- a/events/models.py +++ b/events/models.py @@ -1,3 +1,33 @@ +""" +Event framework for GestioCOF and GestioBDS. + +The events implemented in this module provide two type of customisation to event +creators (the COF and BDS staff): options and extra (text) field. + +Options +------- + +An option is an extra field in the registration form with a predefined list of available +choices. Any number of options can be added to an event. + +For instance, a typical use-case if for events where meals are served to participants +with different possible menus, say: vegeterian / vegan / without pork / etc. This +example can be implemented with an `Option(name="menu")` and an `OptionChoice` for each +available menu. + +In this example, the choice was exclusive: participants can only chose one menu. For +situations, where multiple choices can be made at the same time, use the `multi_choices` +flag. + +Extra fields +------------ + +Extra fields can also be added to the registration form that can receive arbitrary text. +Typically, this can be a "remark" field (prefer the LONGTEXT option in this case) or +small form entries such as "phone number" or "emergency contact" (prefer the SHORTTEXT +option in this case). +""" + from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ @@ -29,12 +59,11 @@ class Event(models.Model): class Option(models.Model): - """Event options to be selected by participants at registration. + """Extra form fields with a limited set of available choices. - The possible choices are instances of `OptionChoice` (see below). A typical example - is when the participants have the choice between different meal types (e.g. vegan / - vegetarian / no pork / with meat). In this case, the "meal type" is an `Option` and - the three alternatives are `OptionChoice`s. + The available choices are given by `OptionChoice`s (see below). A typical use-case + is for events where the participants have the choice between different menus (e.g. + vegan / vegetarian / without-pork / whatever). """ event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="options") @@ -50,7 +79,7 @@ class Option(models.Model): class OptionChoice(models.Model): - """A possible choice for an event option (see Option).""" + """A possible choice for an event option.""" option = models.ForeignKey(Option, on_delete=models.CASCADE, related_name="choices") choice = models.CharField(_("choix"), max_length=200) @@ -64,11 +93,11 @@ class OptionChoice(models.Model): class ExtraField(models.Model): - """Extra event field, for event creators. + """Extra event field receiving arbitrary text. Extra text field that can be added by event creators to the event registration form. - Typical examples are "remarks" fields (of type LONGTEXT) or more specific questions - such as "emergency contact". + Typical examples are "remarks" fields (of type LONGTEXT) or more specific fields + such as "emergency contact" (of type SHORTTEXT probably?). """ LONGTEXT = "longtext" @@ -87,6 +116,8 @@ class ExtraField(models.Model): class ExtraFieldContent(models.Model): + """Value entered in an extra field.""" + field = models.ForeignKey(ExtraField, on_delete=models.CASCADE) registration = models.ForeignKey( "Registration", on_delete=models.CASCADE, related_name="extra_info" @@ -106,6 +137,8 @@ class ExtraFieldContent(models.Model): class Registration(models.Model): + """A user registration to an event.""" + event = models.ForeignKey(Event, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) options_choices = models.ManyToManyField(OptionChoice) From c2f6622a9fc2220ee8edfa58f5994103cee08a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Mon, 23 Dec 2019 00:02:29 +0100 Subject: [PATCH 4/7] Update changelog --- CHANGELOG.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bec3bfde..adb4e464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,25 @@ Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre ## Le FUTUR ! (pas prêt pour la prod) -- Nouveau module de gestion des événements -- Nouveau module BDS -- Nouveau module clubs -- Module d'autocomplétion indépendant des apps +### Nouveau module de gestion des événements + +- Désormais complet niveau modèles +- Export des participants implémenté + +#### TODO + +- Vue de création d'événements ergonomique +- Vue d'inscription à un événement **ou** intégration propre dans la vue + "inscription d'un nouveau membre" + +### Nouveau module BDS + +Uniquement un modèle BDSProfile pour le moment… + +### Nouveau module de gestion des clubs + +Uniquement un modèle simple de clubs avec des respos. Aucune gestion des +adhérents ni des cotisations. ## Upcoming From d7d4d73af33f698e47ae833172cdfeba41671e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Fri, 8 May 2020 16:34:19 +0200 Subject: [PATCH 5/7] typos --- events/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/events/models.py b/events/models.py index e334784e..9b166599 100644 --- a/events/models.py +++ b/events/models.py @@ -1,8 +1,8 @@ """ Event framework for GestioCOF and GestioBDS. -The events implemented in this module provide two type of customisation to event -creators (the COF and BDS staff): options and extra (text) field. +The events implemented in this module provide two types of customisations to event +creators (the COF and BDS staff): options and extra (text) fields. Options ------- @@ -10,7 +10,7 @@ Options An option is an extra field in the registration form with a predefined list of available choices. Any number of options can be added to an event. -For instance, a typical use-case if for events where meals are served to participants +For instance, a typical use-case is events where meals are served to participants with different possible menus, say: vegeterian / vegan / without pork / etc. This example can be implemented with an `Option(name="menu")` and an `OptionChoice` for each available menu. @@ -62,8 +62,8 @@ class Option(models.Model): """Extra form fields with a limited set of available choices. The available choices are given by `OptionChoice`s (see below). A typical use-case - is for events where the participants have the choice between different menus (e.g. - vegan / vegetarian / without-pork / whatever). + is events where the participants have the choice between different menus (e.g. + vegan / vegetarian / without-pork / etc). """ event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="options") From 5a0cf58d8a75c605c57ad15685fe24e98a3b1706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Fri, 8 May 2020 16:34:35 +0200 Subject: [PATCH 6/7] Events: more validation & uniqueness constraints --- events/models.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/events/models.py b/events/models.py index 9b166599..99e97a97 100644 --- a/events/models.py +++ b/events/models.py @@ -29,6 +29,7 @@ option in this case). """ from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -73,6 +74,7 @@ class Option(models.Model): class Meta: verbose_name = _("option d'événement") verbose_name_plural = _("options d'événement") + unique_together = [["event", "name"]] def __str__(self): return self.name @@ -87,6 +89,7 @@ class OptionChoice(models.Model): class Meta: verbose_name = _("choix d'option d'événement") verbose_name_plural = _("choix d'option d'événement") + unique_together = [["option", "choice"]] def __str__(self): return self.choice @@ -114,6 +117,9 @@ class ExtraField(models.Model): name = models.CharField(_("champ d'événement supplémentaire"), max_length=200) field_type = models.CharField(_("type de champ"), max_length=9, choices=FIELD_TYPE) + class Meta: + unique_together = [["event", "name"]] + class ExtraFieldContent(models.Model): """Value entered in an extra field.""" @@ -124,9 +130,16 @@ class ExtraFieldContent(models.Model): ) content = models.TextField(_("contenu du champ")) + def clean(self): + if self.registration.event != self.field.event: + raise ValidationError( + _("Inscription et champ texte incohérents pour ce commentaire") + ) + class Meta: verbose_name = _("contenu d'un champ événement supplémentaire") verbose_name_plural = _("contenus d'un champ événement supplémentaire") + unique_together = [["field", "registration"]] def __str__(self): max_length = 50 @@ -146,6 +159,7 @@ class Registration(models.Model): class Meta: verbose_name = _("inscription à un événement") verbose_name_plural = _("inscriptions à un événement") + unique_together = [["event", "user"]] def __str__(self): return "inscription de {} à {}".format(self.user, self.event) From 24180e747e62e4a2c1b3e0ef068eafb7cc74e4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Fri, 8 May 2020 16:40:18 +0200 Subject: [PATCH 7/7] Events: one more validation check --- events/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/events/models.py b/events/models.py index 99e97a97..7b536c86 100644 --- a/events/models.py +++ b/events/models.py @@ -156,6 +156,12 @@ class Registration(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) options_choices = models.ManyToManyField(OptionChoice) + def clean(self): + if not all((ch.option.event == self.event for ch in self.options_choices)): + raise ValidationError( + _("Choix d'options incohérents avec l'événement pour cette inscription") + ) + class Meta: verbose_name = _("inscription à un événement") verbose_name_plural = _("inscriptions à un événement")