Merge branch 'kerl/event_options_and_extra_fields' into 'master'

Les événements du nouveau module `events` récupèrent les même fonctionnalités que les événements de `gestioncof`

See merge request klub-dev-ens/gestioCOF!398
This commit is contained in:
Ludovic Stephan 2020-05-10 00:53:27 +02:00
commit cc72f47f00
5 changed files with 482 additions and 18 deletions

View file

@ -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

View file

@ -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",
),
),
]

View file

@ -1,4 +1,35 @@
"""
Event framework for GestioCOF and GestioBDS.
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
-------
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 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.
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.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -16,7 +47,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 +59,113 @@ 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):
"""Extra form fields with a limited set of available choices.
# TODO: gérer les champs commentaires (EventCommentField & EventCommentChoice)
# par exemple: "champ "allergies / régime particulier" au Mega
The available choices are given by `OptionChoice`s (see below). A typical use-case
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")
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")
unique_together = [["event", "name"]]
def __str__(self):
return self.name
class OptionChoice(models.Model):
"""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)
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
class ExtraField(models.Model):
"""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 fields
such as "emergency contact" (of type SHORTTEXT probably?).
"""
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 Meta:
unique_together = [["event", "name"]]
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"
)
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
if len(self.content) > max_length:
return self.content[: max_length - 1] + ""
else:
return self.content
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)
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")
unique_together = [["event", "user"]]
def __str__(self):
return "inscription de {} à {}".format(self.user, self.event)

View file

@ -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,74 @@ 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")))
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"],
toto_registration,
)

View file

@ -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,43 @@ 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
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(
registration.extra_info.values_list("content", flat=True).order_by(
"field__id"
)
)
row += extra_info
writer.writerow(row)
return response