From cd4695c27a182121f796723ade322ee97e6910ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 3 Aug 2017 12:01:10 +0200 Subject: [PATCH 1/7] Add some __init__ modules. They are pythonicly not necessary, but Django misses testcases and migrations if they are not present. --- api/__init__.py | 0 api/event/__init__.py | 0 api/users/__init__.py | 0 users/migrations/__init__.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 api/__init__.py create mode 100644 api/event/__init__.py create mode 100644 api/users/__init__.py create mode 100644 users/migrations/__init__.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/event/__init__.py b/api/event/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/users/__init__.py b/api/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 From cd78757244bf6f2e28847387622c0ce43f870d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 3 Aug 2017 12:09:03 +0200 Subject: [PATCH 2/7] Clean some models Add/fix some field names, verbose_name, help_text, on_delete (for foreign keys), etc. --- .../migrations/0002_auto_20170802_2323.py | 21 ++ event/migrations/0004_auto_20170802_2323.py | 136 +++++++++++ event/models.py | 224 ++++++++++-------- event/validators.py | 10 + 4 files changed, 287 insertions(+), 104 deletions(-) create mode 100644 equipment/migrations/0002_auto_20170802_2323.py create mode 100644 event/migrations/0004_auto_20170802_2323.py create mode 100644 event/validators.py diff --git a/equipment/migrations/0002_auto_20170802_2323.py b/equipment/migrations/0002_auto_20170802_2323.py new file mode 100644 index 0000000..55a2402 --- /dev/null +++ b/equipment/migrations/0002_auto_20170802_2323.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-02 23:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='equipment', + name='event', + field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), + ), + ] diff --git a/event/migrations/0004_auto_20170802_2323.py b/event/migrations/0004_auto_20170802_2323.py new file mode 100644 index 0000000..22c4349 --- /dev/null +++ b/event/migrations/0004_auto_20170802_2323.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-02 23:23 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('event', '0003_auto_20170726_1116'), + ] + + operations = [ + migrations.RemoveField( + model_name='activity', + name='place', + ), + migrations.RemoveField( + model_name='activitytemplate', + name='place', + ), + migrations.AddField( + model_name='activity', + name='places', + field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'), + ), + migrations.AddField( + model_name='activitytemplate', + name='places', + field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'), + ), + migrations.AlterField( + model_name='activity', + name='description', + field=models.TextField(blank=True, help_text="Visible par tout le monde si l'événément est public.", null=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='activity', + name='event', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), + ), + migrations.AlterField( + model_name='activity', + name='has_perm', + field=models.NullBooleanField(verbose_name='inscription de permanents'), + ), + migrations.AlterField( + model_name='activity', + name='is_public', + field=models.NullBooleanField(verbose_name='est public'), + ), + migrations.AlterField( + model_name='activity', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='event.ActivityTemplate', verbose_name='template'), + ), + migrations.AlterField( + model_name='activity', + name='remarks', + field=models.TextField(blank=True, help_text='Visible uniquement par les organisateurs.', null=True, verbose_name='remarques'), + ), + migrations.AlterField( + model_name='activity', + name='staff', + field=models.ManyToManyField(blank=True, related_name='in_perm_activities', to=settings.AUTH_USER_MODEL, verbose_name='permanents'), + ), + migrations.AlterField( + model_name='activity', + name='tags', + field=models.ManyToManyField(blank=True, to='event.ActivityTag', verbose_name='tags'), + ), + migrations.AlterField( + model_name='activitytag', + name='color', + field=models.CharField(help_text='Rentrer une couleur en hexadécimal (#XXX ou #XXXXXX).', max_length=7, validators=[django.core.validators.RegexValidator(message="La chaîne de caractère rentrée n'est pas une couleur en hexadécimal.", regex='^#(?:[0-9a-fA-F]{3}){1,2}$')], verbose_name='couleur'), + ), + migrations.AlterField( + model_name='activitytag', + name='event', + field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), + ), + migrations.AlterField( + model_name='activitytag', + name='is_public', + field=models.BooleanField(help_text="Sert à faire une distinction dans l'affichage selon que le tag soit destiné au public ou à l'organisation.", verbose_name='est public'), + ), + migrations.AlterField( + model_name='activitytemplate', + name='description', + field=models.TextField(blank=True, help_text="Visible par tout le monde si l'événément est public.", null=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='activitytemplate', + name='event', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), + ), + migrations.AlterField( + model_name='activitytemplate', + name='has_perm', + field=models.NullBooleanField(verbose_name='inscription de permanents'), + ), + migrations.AlterField( + model_name='activitytemplate', + name='is_public', + field=models.NullBooleanField(verbose_name='est public'), + ), + migrations.AlterField( + model_name='activitytemplate', + name='remarks', + field=models.TextField(blank=True, help_text='Visible uniquement par les organisateurs.', null=True, verbose_name='remarques'), + ), + migrations.AlterField( + model_name='activitytemplate', + name='tags', + field=models.ManyToManyField(blank=True, to='event.ActivityTag', verbose_name='tags'), + ), + migrations.AlterField( + model_name='event', + name='created_by', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL, verbose_name='créé par'), + ), + migrations.AlterField( + model_name='event', + name='slug', + field=models.SlugField(help_text="Seulement des lettres, des chiffres ou les caractères '_' ou '-'.", unique=True, verbose_name='identificateur'), + ), + migrations.AlterField( + model_name='place', + name='event', + field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), + ), + ] diff --git a/event/models.py b/event/models.py index eebe9b3..cd96040 100644 --- a/event/models.py +++ b/event/models.py @@ -1,36 +1,48 @@ from django.contrib.auth import get_user_model -from django.utils.translation import ugettext_lazy as _ from django.core.validators import RegexValidator -from django.core.exceptions import FieldError +from django.core.exceptions import FieldDoesNotExist, FieldError from django.db import models +from django.utils.translation import ugettext_lazy as _ + from communication.models import SubscriptionMixin User = get_user_model() +ColorValidator = RegexValidator( + regex=r'^#(?:[0-9a-fA-F]{3}){1,2}$', + message=_( + "La chaîne de caractère rentrée n'est pas une couleur en hexadécimal." + ), +) + + class Event(SubscriptionMixin, models.Model): title = models.CharField( - _("nom de l'évènement"), - max_length=200, - ) + _("nom de l'évènement"), + max_length=200, + ) slug = models.SlugField( - _('identificateur'), - unique=True, - help_text=_("Seulement des lettres, des chiffres ou" - "les caractères '_' ou '-'."), - ) + _("identificateur"), + unique=True, + help_text=_( + "Seulement des lettres, des chiffres ou les caractères '_' ou '-'." + ), + ) created_by = models.ForeignKey( - User, - related_name="created_events", - editable=False, - ) + User, + verbose_name=_("créé par"), + on_delete=models.SET_NULL, + related_name="created_events", + editable=False, null=True, + ) created_at = models.DateTimeField( - _('date de création'), - auto_now_add=True, - ) - description = models.TextField(_('description')) - beginning_date = models.DateTimeField(_('date de début')) - ending_date = models.DateTimeField(_('date de fin')) + _("date de création"), + auto_now_add=True, + ) + description = models.TextField(_("description")) + beginning_date = models.DateTimeField(_("date de début")) + ending_date = models.DateTimeField(_("date de fin")) class Meta: verbose_name = _("évènement") @@ -45,12 +57,14 @@ class EventSpecificMixin(models.Model): or not (depending on whether the event field is null)""" event = models.ForeignKey( - 'event.Event', + Event, verbose_name=_("évènement"), - help_text=_("Si spécifié, l'instance du modèle " - "est spécifique à l'évènement en question"), - blank=True, - null=True + help_text=_( + "Si spécifié, l'instance du modèle est spécifique à l'évènement " + "en question." + ), + on_delete=models.CASCADE, + blank=True, null=True, ) class Meta: @@ -59,9 +73,9 @@ class EventSpecificMixin(models.Model): class Place(EventSpecificMixin, models.Model): name = models.CharField( - _("nom du lieu"), - max_length=200, - ) + _("nom du lieu"), + max_length=200, + ) description = models.TextField(blank=True) class Meta: @@ -74,26 +88,22 @@ class Place(EventSpecificMixin, models.Model): class ActivityTag(EventSpecificMixin, models.Model): name = models.CharField( - _("nom du tag"), - max_length=200, - ) + _("nom du tag"), + max_length=200, + ) is_public = models.BooleanField( - help_text=_("Sert à faire une distinction dans" - " l'affichage selon que cela soit" - " destiné au public ou à l'équipe" - " organisatrice"), - ) - color_regex = RegexValidator( - regex=r'^#(?:[0-9a-fA-F]{3}){1,2}$', - message=_("La chaîne de caractère rentrée n'est pas" - " une couleur en hexadécimal."), - ) + _("est public"), + help_text=_( + "Sert à faire une distinction dans l'affichage selon que le tag " + "soit destiné au public ou à l'organisation." + ), + ) color = models.CharField( - _('couleur'), - max_length=7, - validators=[color_regex], - help_text=_("Rentrer une couleur en hexadécimal"), - ) + _('couleur'), + max_length=7, + validators=[ColorValidator], + help_text=_("Rentrer une couleur en hexadécimal (#XXX ou #XXXXXX)."), + ) class Meta: verbose_name = _("tag") @@ -105,49 +115,53 @@ class ActivityTag(EventSpecificMixin, models.Model): class AbstractActivityTemplate(SubscriptionMixin, models.Model): title = models.CharField( - _("nom de l'activité"), - max_length=200, - blank=True, - null=True, - ) + _("nom de l'activité"), + max_length=200, + blank=True, null=True, + ) # FIXME: voir comment on traite l'héritage de `event` - event = models.ForeignKey(Event) + event = models.ForeignKey( + Event, + verbose_name=_("évènement"), + on_delete=models.CASCADE, + editable=False, + ) is_public = models.NullBooleanField( - blank=True, - ) + _("est public"), + blank=True, + ) has_perm = models.NullBooleanField( - blank=True, - ) + _("inscription de permanents"), + blank=True, + ) min_perm = models.PositiveSmallIntegerField( - _('nombre minimum de permanents'), - blank=True, - null=True, - ) + _('nombre minimum de permanents'), + blank=True, null=True, + ) max_perm = models.PositiveSmallIntegerField( - _('nombre maximum de permanents'), - blank=True, - null=True, - ) + _('nombre maximum de permanents'), + blank=True, null=True, + ) description = models.TextField( - _('description'), - help_text=_("Public, Visible par tout le monde."), - blank=True, - null=True, - ) + _('description'), + help_text=_("Visible par tout le monde si l'événément est public."), + blank=True, null=True, + ) remarks = models.TextField( - _('remarques'), - help_text=_("Visible uniquement par les organisateurs"), - blank=True, - null=True, - ) + _('remarques'), + help_text=_("Visible uniquement par les organisateurs."), + blank=True, null=True, + ) tags = models.ManyToManyField( - ActivityTag, - blank=True, - ) - place = models.ManyToManyField( - Place, - blank=True, - ) + ActivityTag, + verbose_name=_('tags'), + blank=True, + ) + places = models.ManyToManyField( + Place, + verbose_name=_('lieux'), + blank=True, + ) class Meta: abstract = True @@ -164,40 +178,42 @@ class ActivityTemplate(AbstractActivityTemplate): class Activity(AbstractActivityTemplate): parent = models.ForeignKey( - ActivityTemplate, - related_name="children", - blank=True, - null=True, - ) + ActivityTemplate, + verbose_name=_("template"), + on_delete=models.PROTECT, + related_name="children", + blank=True, null=True, + ) staff = models.ManyToManyField( - User, - related_name="in_perm_activities", - blank=True, - ) + User, + verbose_name=_("permanents"), + related_name="in_perm_activities", + blank=True, + ) beginning = models.DateTimeField(_("heure de début")) end = models.DateTimeField(_("heure de fin")) def get_herited(self, attrname): - inherited_fields = [f.name for f in - ActivityTemplate._meta.get_fields()] - m2m_fields = [f.name for f in ActivityTemplate._meta.get_fields() - if f.many_to_many] - attr = getattr(self, attrname) - if attrname not in inherited_fields: + try: + tpl_field = ActivityTemplate._meta.get_field(attrname) + except FieldDoesNotExist: raise FieldError( - _("%(attrname)s n'est pas un champ héritable"), - params={'attrname': attrname}, - ) - elif attrname in m2m_fields: - if attr.exists(): - return attr + "%(attrname)s field can't be herited.", + params={'attrname': attrname}, + ) + + value = getattr(self, attrname) + + if tpl_field.many_to_many: + if value.exists(): + return value else: return getattr(self.parent, attrname) - elif attr is None: + elif value is None: return getattr(self.parent, attrname) else: - return attr + return value class Meta: verbose_name = _("activité") diff --git a/event/validators.py b/event/validators.py new file mode 100644 index 0000000..46c2d19 --- /dev/null +++ b/event/validators.py @@ -0,0 +1,10 @@ +from django.core.validators import RegexValidator +from django.utils.translation import ugettext_lazy as _ + + +ColorValidator = RegexValidator( + regex=r'^#(?:[0-9a-fA-F]{3}){1,2}$', + message=_( + "La chaîne de caractère rentrée n'est pas une couleur en hexadécimal." + ), +) From 07d4c3ead1439cce2f51a8e4b42b60dfbd3e44fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 3 Aug 2017 12:14:15 +0200 Subject: [PATCH 3/7] Reflect changes on validators/fields --- event/models.py | 11 ++--------- event/tests.py | 8 ++++---- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/event/models.py b/event/models.py index cd96040..7560ae1 100644 --- a/event/models.py +++ b/event/models.py @@ -1,22 +1,15 @@ from django.contrib.auth import get_user_model -from django.core.validators import RegexValidator from django.core.exceptions import FieldDoesNotExist, FieldError from django.db import models from django.utils.translation import ugettext_lazy as _ from communication.models import SubscriptionMixin +from .validators import ColorValidator + User = get_user_model() -ColorValidator = RegexValidator( - regex=r'^#(?:[0-9a-fA-F]{3}){1,2}$', - message=_( - "La chaîne de caractère rentrée n'est pas une couleur en hexadécimal." - ), -) - - class Event(SubscriptionMixin, models.Model): title = models.CharField( _("nom de l'évènement"), diff --git a/event/tests.py b/event/tests.py index 36dee90..29c840f 100644 --- a/event/tests.py +++ b/event/tests.py @@ -97,14 +97,14 @@ class ActivityInheritanceTest(TestCase): self.assertEqual(self.real_act.get_herited('max_perm'), 1) def test_inherits_place(self): - self.template_act.place.add(self.loge) + self.template_act.places.add(self.loge) self.assertEqual( - self.real_act.get_herited('place').get(), + self.real_act.get_herited('places').get(), self.loge ) - self.real_act.place.add(self.aqua) + self.real_act.places.add(self.aqua) self.assertEqual( - self.real_act.get_herited('place').get(), + self.real_act.get_herited('places').get(), self.aqua ) From fc4930a49eb8cbf12255068b1b0207b29f0a0945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 3 Aug 2017 12:14:53 +0200 Subject: [PATCH 4/7] Clean serializers and viewsets Event-based urls - Add viewset mixin 'EventUrlViewSetMixin' to get the event from the 'event_pk' url kwarg of a view. - Add url serializer fields for object which can be accessed with a root-level and/or event-specific url ('EventHyperlinked*Field). Update viewsets and serializers to use these + clean inheritance viewsets. --- api/event/fields.py | 33 +++++++++++ api/event/serializers.py | 114 ++++++++++++++------------------------ api/event/views.py | 116 +++++++++++++++++++++++---------------- api/urls.py | 29 +++++----- 4 files changed, 157 insertions(+), 135 deletions(-) create mode 100644 api/event/fields.py diff --git a/api/event/fields.py b/api/event/fields.py new file mode 100644 index 0000000..4df025c --- /dev/null +++ b/api/event/fields.py @@ -0,0 +1,33 @@ +from rest_framework import serializers +from rest_framework.reverse import reverse + + +class EventHyperlinkedFieldMixin: + + def get_url(self, obj, view_name, request, format): + url_kwargs = {'pk': obj.pk} + if getattr(obj, 'event', None): + url_kwargs['event_pk'] = obj.event.pk + return reverse( + view_name, kwargs=url_kwargs, request=request, format=format) + + def get_object(self, view_name, view_args, view_kwargs): + lookup_kwargs = { + 'pk': view_kwargs['pk'], + 'event_id': view_kwargs.get('event_pk'), + } + return self.get_queryset().get(**lookup_kwargs) + + +class EventHyperlinkedRelatedField( + EventHyperlinkedFieldMixin, + serializers.HyperlinkedRelatedField, + ): + pass + + +class EventHyperlinkedIdentityField( + EventHyperlinkedFieldMixin, + serializers.HyperlinkedIdentityField + ): + pass diff --git a/api/event/serializers.py b/api/event/serializers.py index 44461e6..a7ff849 100644 --- a/api/event/serializers.py +++ b/api/event/serializers.py @@ -1,53 +1,30 @@ -from django.shortcuts import get_object_or_404 +from django.db import transaction +from django.utils.decorators import method_decorator + from rest_framework import serializers + from event.models import Event, ActivityTag, Place, ActivityTemplate +from .fields import EventHyperlinkedIdentityField + # Event Serializer -class EventSerializer(serializers.HyperlinkedModelSerializer): +class EventSerializer(serializers.ModelSerializer): + # TODO: Change this to a nested serializer ~(url, full_name) of User created_by = serializers.ReadOnlyField(source='created_by.get_full_name') - created_at = serializers.ReadOnlyField() class Meta: model = Event - fields = ('url', 'id', 'title', 'slug', 'created_by', 'created_at', - 'description', 'beginning_date', 'ending_date') - - -# Classes utilitaires -class EventSpecificSerializerMixin(): - """ - Provide `update` and `create` methods for nested view with an Event - For example for Models which extends EventSpecificMixin - the event id has to be provided in the `save` method - Works fine with view.EventSpecificViewSet - Also provides : - event = eventserializer(allow_null=true, read_only=true) - """ - event = EventSerializer(allow_null=True, read_only=True) - - def update(self, instance, validated_data): - """ - Note : does NOT change the event value of the instance - """ - validated_data.pop('event_pk') - [setattr(instance, key, value) - for key, value in validated_data.items()] - instance.save() - return instance - - def create(self, validated_data): - ModelClass = self.Meta.model - event_pk = validated_data.pop('event_pk', None) - event = event_pk and get_object_or_404(Event, id=event_pk) or None - instance = ModelClass.objects.create(event=event, **validated_data) - return instance + fields = ( + 'url', 'id', 'title', 'slug', 'created_by', 'created_at', + 'description', 'beginning_date', 'ending_date', + ) # Serializers # TODO rajouter des permissions -class PlaceSerializer(EventSpecificSerializerMixin, - serializers.ModelSerializer): +class PlaceSerializer(serializers.ModelSerializer): + serializer_url_field = EventHyperlinkedIdentityField class Meta: model = Place @@ -55,8 +32,9 @@ class PlaceSerializer(EventSpecificSerializerMixin, # TODO rajouter des permissions -class ActivityTagSerializer(EventSpecificSerializerMixin, - serializers.ModelSerializer): +class ActivityTagSerializer(serializers.ModelSerializer): + serializer_url_field = EventHyperlinkedIdentityField + class Meta: model = ActivityTag fields = ('url', 'id', 'name', 'is_public', 'color', 'event') @@ -64,36 +42,30 @@ class ActivityTagSerializer(EventSpecificSerializerMixin, # TODO rajouter des permissions class ActivityTemplateSerializer(serializers.ModelSerializer): - event = EventSerializer(read_only=True) tags = ActivityTagSerializer(many=True) + serializer_url_field = EventHyperlinkedIdentityField + class Meta: model = ActivityTemplate - fields = ('id', 'title', 'event', 'is_public', 'has_perm', - 'min_perm', 'max_perm', 'description', 'remarks', 'tags',) + fields = ( + 'url', 'id', 'title', 'event', 'is_public', 'has_perm', 'min_perm', + 'max_perm', 'description', 'remarks', 'tags', + ) - def update(self, instance, validated_data): - """ - @tags comportement attendu : si l'id existe déjà on ne change pas - les autres champs et si l'id n'existe pas on le créé - """ - tags_data = validated_data.pop('tags') - validated_data.pop('event_pk') - event = instance.event - [setattr(instance, key, value) - for key, value in validated_data.items()] - instance.save() + def process_tags(self, instance, tags_data): # TODO: en fonction de si backbone envoie un `id` ou non lorsque le tag # n'existe pas encore il faudra faire un premier passage sur `tags` i # pour s'assurer que le get ne foire pas le get si, par exemple, le tag # été modifié entre temps dans la base de donnée (mais pas sur la # classe backbone + tags = [] for tag_data in tags_data: - tag_data.pop('event', None) - tags = [ActivityTag.objects.get_or_create(event=event, **tag_data)[0] - for tag_data in tags_data] - instance.tags.set(tags) - return instance + tag, _ = ActivityTag.objects.get_or_create(**tag_data, defaults={ + 'event': instance.event, + }) + tags.append(tag) + instance.tags.add(*tags) def create(self, validated_data): """ @@ -101,20 +73,18 @@ class ActivityTemplateSerializer(serializers.ModelSerializer): les autres champs et si l'id n'existe pas on le créé """ tags_data = validated_data.pop('tags') - event_pk = validated_data.pop('event_pk') - event = event_pk and get_object_or_404(Event, id=event_pk) or None - activity_template = ActivityTemplate.objects.create(event=event, - **validated_data) - # TODO: en fonction de si backbone envoie un `id` ou non lorsque le tag - # n'existe pas encore il faudra faire un premier passage sur `tags` i - # pour s'assurer que le get ne foire pas le get si, par exemple, le tag - # été modifié entre temps dans la base de donnée (mais pas sur la - # classe backbone - for tag_data in tags_data: - tag_data.pop('event', None) - tags = [ActivityTag.objects.get_or_create(event=event, **tag_data)[0] - for tag_data in tags_data] - activity_template.tags = tags + activity_template = super().create(validated_data) + self.process_tags(activity_template, tags_data) + return activity_template + + def update(self, instance, validated_data): + """ + @tags comportement attendu : si l'id existe déjà on ne change pas + les autres champs et si l'id n'existe pas on le créé + """ + tags_data = validated_data.pop('tags') + activity_template = super().update(instance, validated_data) + self.process_tags(activity_template, tags_data) return activity_template diff --git a/api/event/views.py b/api/event/views.py index b360583..3497f3a 100644 --- a/api/event/views.py +++ b/api/event/views.py @@ -1,17 +1,54 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property from rest_framework.viewsets import ModelViewSet +from rest_framework.filters import OrderingFilter -from api.event.serializers import EventSerializer, PlaceSerializer,\ - ActivityTagSerializer, ActivityTemplateSerializer, ActivitySerializer -from event.models import Event, Place, ActivityTag, ActivityTemplate, Activity +from event.models import Activity, ActivityTag, ActivityTemplate, Event, Place + +from .serializers import ( + ActivitySerializer, ActivityTagSerializer, ActivityTemplateSerializer, + EventSerializer, PlaceSerializer, +) User = get_user_model() # classes utilitaires -class EventSpecificViewSet(ModelViewSet): + +class EventUrlViewSetMixin: + """ + ViewSet mixin to handle the evenk_pk from url. + """ + + @cached_property + def event(self): + event_pk = self.kwargs.get('event_pk') + if event_pk: + return get_object_or_404(Event, pk=event_pk) + return None + + +class EventModelViewSetMixin: + + def perform_create(self, serializer): + serializer.save(event=self.event) + + def perform_update(self, serializer): + serializer.save(event=self.event) + + +class EventModelViewSet( + EventModelViewSetMixin, + EventUrlViewSetMixin, + ModelViewSet, + ): + pass + + +class EventSpecificModelViewSet(EventModelViewSet): """ ViewSet that returns : * rootlevel objects if no Event is specified @@ -21,81 +58,64 @@ class EventSpecificViewSet(ModelViewSet): to the save method. Works fine with serializers.EventSpecificSerializer Useful for models that extends EventSpecificMixin """ + def get_queryset(self): """ Warning : You may want to override this method and not call with super """ - event_pk = self.kwargs.get('event_pk') queryset = super().get_queryset() - if event_pk: - return queryset.filter(Q(event=event_pk) | Q(event=None)) - return queryset.filter(event=None) - - def perform_create(self, serializer): - event_pk = self.kwargs.get('event_pk') - serializer.save(event_pk=event_pk) - - def perform_update(self, serializer): - event_pk = self.kwargs.get('event_pk') - serializer.save(event_pk=event_pk) + filters = Q(event=None) + if self.event: + filters |= Q(event=self.event) + return queryset.filter(filters) # ViewSets class EventViewSet(ModelViewSet): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions. - - """ queryset = Event.objects.all() serializer_class = EventSerializer + filter_backends = (OrderingFilter,) + ordering_fields = ('title', 'creation_date', 'beginning_date', + 'ending_date', ) + ordering = ('beginning_date', ) + def perform_create(self, serializer): serializer.save(created_by=self.request.user) -class PlaceViewSet(EventSpecificViewSet): +class PlaceViewSet(EventSpecificModelViewSet): queryset = Place.objects.all() serializer_class = PlaceSerializer + filter_backends = (OrderingFilter,) + ordering_fields = ('name', ) + ordering = ('name', ) -class ActivityTagViewSet(EventSpecificViewSet): + +class ActivityTagViewSet(EventSpecificModelViewSet): queryset = ActivityTag.objects.all() serializer_class = ActivityTagSerializer + filter_backends = (OrderingFilter,) + ordering_fields = ('is_public', 'name', ) + ordering = ('is_public', 'name', ) -class ActivityTemplateViewSet(ModelViewSet): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions. - """ +class ActivityTemplateViewSet(EventModelViewSet): queryset = ActivityTemplate.objects.all() serializer_class = ActivityTemplateSerializer - def perform_create(self, serializer): - event_pk = self.kwargs.get('event_pk') - serializer.save(event_pk=event_pk) - - def perform_update(self, serializer): - event_pk = self.kwargs.get('event_pk') - serializer.save(event_pk=event_pk) + filter_backends = (OrderingFilter,) + ordering_fields = ('title', ) + ordering = ('title', ) -class ActivityViewSet(ModelViewSet): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions. - - """ +class ActivityViewSet(EventModelViewSet): queryset = Activity.objects.all() serializer_class = ActivitySerializer - def perform_create(self, serializer): - event_pk = self.kwargs.get('event_pk') - serializer.save(event_pk=event_pk) - - def perform_update(self, serializer): - event_pk = self.kwargs.get('event_pk') - serializer.save(event_pk=event_pk) + filter_backends = (OrderingFilter,) + ordering_fields = ('title', ) + ordering = ('title', ) diff --git a/api/urls.py b/api/urls.py index 0409143..1a704a0 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,27 +1,26 @@ from django.conf.urls import url, include + from rest_framework_nested.routers import SimpleRouter, NestedSimpleRouter -from api.event.views import EventViewSet, PlaceViewSet, ActivityTagViewSet,\ - ActivityTemplateViewSet -# Create a router and register our viewsets with it. +from api.event import views + + router = SimpleRouter() -router.register(r'event', EventViewSet, 'event') -router.register(r'place', PlaceViewSet, 'place') -router.register(r'activitytag', ActivityTagViewSet, 'activitytag') +router.register(r'event', views.EventViewSet) +router.register(r'place', views.PlaceViewSet) +router.register(r'tag', views.ActivityTagViewSet) -# Register nested router and register someviewsets vith it + +# Views behind /event//... event_router = NestedSimpleRouter(router, r'event', lookup='event') -event_router.register(r'place', PlaceViewSet, base_name='event-place') -event_router.register(r'tag', ActivityTagViewSet, base_name='event-activitytag') -event_router.register(r'activitytemplate', ActivityTemplateViewSet, - base_name='event-activitytemplate') +event_router.register(r'place', views.PlaceViewSet) +event_router.register(r'tag', views.ActivityTagViewSet) +event_router.register(r'template', views.ActivityTemplateViewSet) -# The API URLs are now determined automatically by the router. -# Additionally, we include the login URLs for the browsable API. +# API URLconf: routers + auth for browsable API. urlpatterns = [ url(r'^', include(router.urls)), url(r'^', include(event_router.urls)), - url(r'^api-auth/', include('rest_framework.urls', - namespace='rest_framework')) + url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), ] From f4041d6e0258c696a2a639bd4fd9fe499fb37663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 3 Aug 2017 12:11:42 +0200 Subject: [PATCH 5/7] Prepare base structures for API' tests. --- api/test/testcases.py | 694 ++++++++++++++++++++++++++++++++ api/test/utils.py | 19 + api/testcases.py | 292 -------------- evenementiel/settings/common.py | 1 + 4 files changed, 714 insertions(+), 292 deletions(-) create mode 100644 api/test/testcases.py create mode 100644 api/test/utils.py delete mode 100644 api/testcases.py diff --git a/api/test/testcases.py b/api/test/testcases.py new file mode 100644 index 0000000..fb879e4 --- /dev/null +++ b/api/test/testcases.py @@ -0,0 +1,694 @@ +from unittest import mock + +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.urls import reverse + +from rest_framework import status + +User = get_user_model() + + +class GenericAPITestCaseMixin: + """ + Base mixin for testing one or more operations on a model. + + The specifics of each operation is not implemented here. + Each operation has its own TestCase, which relies on methods/attributes + defined here (some or all). + + The "real" mixins for each operation are: + [List,Create,Retrieve,Update,Destroy]APITestCaseMixin. + + Example: + + E.g. for a creation test, your testcase should be something like: + + class MyModelCreateAPITestCase( + CreateAPITestCaseMixin, + GenericAPITestCaseMixin, + APITestCase, + ): + ... + + Attributes: + # General + model (models.Model): The model class under test. + url_label (str): Use to get the API urls of the model under test: + - The model url is:`reverse('{url_label}-list). + This is used by `[List,Create]APITestCaseMixin`. + The url can also be customized by defining `get_url_model` + method. + - The url of a model object is: `reverse('{label}-detail')`. + This is used by `[Retrieve,Update,Destroy]APITestCaseMixin`. + The url can also be customized by defining `get_url_object` + method. + + # Authentication + user_auth (User instance): If given, this user is authenticated for + each request sent to the API. Default to `None`. + See section 'Authenticate requests'. + user_auth_mapping (dict of str: User instance): Associates a user to + authenticate for each type of API request. + See section 'Authenticate requests'. + + Authenticate requests: + Each real testcase call `get_user` method with a label for the + operation under test (e.g. UpdateAPITestCaseMixin will call + `get_user('update')`. If it returns a user, this latter is + authenticated with `force_authenticate` method of the test client. + + The default behavior, which can be customized by replacing `get_user` + method, is to return `user_auth` attribute/property if not None. Else, + `user_auth_mapping[operation_type_label]` or None if key is missing. + + A basic user is created during setUp. It can be found as `user` + attribute. + + """ + model = None + url_label = '' + + user_auth = None + user_auth_mapping = {} + + def setUp(self): + """ + Prepare a test execution. + + Don't forget to call super().setUp() if you define this in subclass. + """ + # Dict comparison can be large. + self.maxDiff = 2000 + + # This will be returned by django.utils.timezone.now thanks to the mock + # in test methods. + # This can be useful to check auto_now and auto_now_add fields. + self.now = timezone.now() + + # A very basic user. + self.user = User.objects.create_user( + username='user', password='user', + first_name='first', last_name='last', + ) + + def get_user(self, action): + """ + Returns a user to authenticate for requests of type 'action'. + + Property `user_auth` has precedence over the property + `user_auth_mapping`. + + Args: + action (str): Operation label. See LCRUD TestCases for reserved + labels, before setting your own if you create a new common + operation TestCase. + + Returns: + User instance | None: + If None, no user should be authenticated for this request. + Else, the returned user should be authenticated. + """ + if self.user_auth is not None: + return self.user_auth + return self.user_auth_mapping.get(action) + + def get_url_model(self, *args, **kwargs): + """ + Returns the API url for the model. + + Default to a reverse on `{url_label}-list`. + + Used by `[List,Create]APITestCaseMixin`. + + """ + return reverse( + '{label}-list'.format(label=self.url_label), + args=args, kwargs=kwargs, + ) + + def get_url_object(self, *args, **kwargs): + """ + Returns the API url for a particular object of model. + + Default to a reverse on `{url_label}-detail`. + + Used by `[Retrieve,Update,Destroy]APITestCaseMixin`. + + """ + return reverse( + '{label}-detail'.format(label=self.url_label), + args=args, kwargs=kwargs, + ) + + def db_create(self, data): + """ + Create a model instance in DB from data given as argument. + + `data` can define M2M fields. + + Operations: + - Create object from non-M2M fields. + - Link the M2M fields with `set` on the created object. + + Returns: + The created object. + """ + fields, m2m_fields = {}, {} + for name, value in data.items(): + if self.model._meta.get_field(name).many_to_many: + m2m_fields[name] = value + else: + fields[name] = value + + instance = self.model.objects.create(**fields) + for name, value in m2m_fields.items(): + getattr(instance, name).set(value) + + return instance + + def get_expected_data(self, instance): + """ + Returns the data the API should reply for the object `instance`. + + This should be the same result of the serializer used for the sent + request. + + Must be defined by subclasses, except for DestroyAPITestCaseMixin. + + Args: + instance (a `model` object) + + Returns: + JSON-decoded data of this instance. + + """ + raise NotImplementedError( + "Subclass must implement 'get_expected_data(instance)' method." + ) + + def assertInstanceEqual(self, instance, expected): + """ + Checks if instance verifies expected. + + For each key/value pair of expected, it verifies that instance.key is + equal to value. `assertEqual` is used, except for M2M fields where + `assertQuerysetEqual(ordered=True, ...)` is used. + + Args: + instance (`model` object): E.g. obtained from DB after a create + operation. + expected (dict): Expected data of instance. + + """ + for key, exp_value in expected.items(): + field = self.model._meta.get_field(key) + value = getattr(instance, key) + if field.many_to_many: + self.assertQuerysetEqual( + value.all(), map(repr, exp_value), + ordered=False, + ) + else: + self.assertEqual(value, exp_value) + + @property + def instances_data(self): + """ + Instances data of the model which will be created if necessary. + + For example, ListAPITestCaseMixin uses these before sending the list + request. + + The first is also used, if `instance_data` is not re-defined, for RUD + operations. + + """ + raise NotImplementedError( + "Property or attribute 'instances_data' must be declared by " + "subclass." + ) + + @property + def instance_data(self): + """ + Data of a single instance of model. + + For example, this data is used to create the instance of model to apply + RUD operations. + + Default to the first item of the `instances_data` property. + + """ + return self.instances_data[0] + + +class ListAPITestCaseMixin: + """ + Mixin to test the "List" API request of a model. + + Has hooks to check the returned objects and their ordering. + + Authentication: + This operation use the label 'list'. + See `GenericAPITestCaseMixin`#`Authenticate requests` for further + informations. + + Attributes: + list_ordering (list of str | str): The ordering of objects that should + be respected by the API response. + Same format as the ordering Meta attribute of models, e.g. + '-created_at' or ['-event', 'created_at']. + Ordering on '__' fields are currently not supported. + + Todo: + * Allow '__' in ordering. + * Support pages. Currently we expect returned objects are on a single + page. + + """ + list_ordering = [] + + @property + def list_expected(self): + """ + Model instances the API should returned. + + Default to all objects of models. + Eventually sorted according to `list_ordering` attribute. + + """ + qs = self.model.objects.all() + if self.list_ordering: + qs = qs.order_by(self.list_ordering) + return qs + + @property + def list_expected_count(self): + """ + Number of objects the API should returned. + + Default to the length of expected returned objects. + """ + return len(self.list_expected) + + @property + def list_expected_ordering(self): + """ + Use this to get expected ordering, instead of relying directly on the + `list_ordering`, if you write subclasses. + """ + if isinstance(self.list_ordering, (tuple, list)): + return self.list_ordering + return [self.list_ordering] + + def assertOrdered(self, results, ordering): + """ + Check `results` are sorted according to `ordering`. + + Attributes: + results (list of model instances) + ordering (list of fields as str) + + """ + _ordering = [ + (f[1:], True) if f.startswith('-') else (f, False) + for f in ordering + ] + + it = iter(results) + + previous = next(it) + + for current in it: + self._assertOrdered(previous, current, _ordering) + previous = current + + def _assertOrdered(self, d1, d2, ordering): + if not ordering: + return True + + field, desc = ordering[0] + + v1, v2 = d1[field], d2[field] + + if v1 == v2: + self._assertOrdered(v1, v2, ordering[1:]) + elif desc: + self.assertGreater(v1, v2, msg=( + "Ordering on '%s' (DESC) is not respected for the following " + "objects.\n- First: %r.\n- Second: %r." + % (field, d1, d2) + )) + else: + self.assertLess(v1, v2, msg=( + "Ordering on '%s' (ASC) is not respected for the following " + "objects:\n- First: %r.\n- Second: %r." + % (field, d1, d2) + )) + + @mock.patch('django.utils.timezone.now') + def test_list(self, mock_now): + """ + The test. + + Preparation: + - Create instances from `instances_data` property. + - Optionally, authenticate client. + + Execution: + - HTTP GET request on model url. + + Check: + - Response status code: 200. + - The base response: count (pages not supported). + - Results (serialized instances) are formatted as expected and + their ordering. + + """ + mock_now.return_value = self.now + + # Setup database. + + for data in self.instances_data: + self.db_create(data) + + # Call API to get instances list. + user = self.get_user('list') + if user: + self.client.force_authenticate(user) + + r = self.client.get(self.get_url_model()) + + # Check API response. + self.assertEqual(r.status_code, status.HTTP_200_OK) + + base_response = { + 'count': self.list_expected_count, + 'previous': None, + 'next': None, + } + self.assertDictContainsSubset(base_response, r.data) + + results = r.data['results'] + + # Check API response ordering. + self.assertOrdered(results, self.list_expected_ordering) + + # Check API response data. + for result, instance in zip(results, self.list_expected): + self.assertDictEqual(result, self.get_expected_data(instance)) + + +class CreateAPITestCaseMixin: + """ + Mixin to test the "Create" API request of a model. + + Authentication: + This operation use the label 'create'. + See `GenericAPITestCaseMixin`#`Authenticate requests` for further + informations. + + Note: + The request is sent assuming the payload is JSON. + + """ + + @property + def create_data(self): + """ + Payload of the create request sent to the API. + """ + raise NotImplementedError( + "Subclass must define a 'create_data' attribute/property. This is " + "the payload of the POST request to the model url." + ) + + @property + def create_expected(self): + """ + Data of model instance which should be created. + """ + raise NotImplementedError( + "Subclass must define a create_expected attribute/property. It is " + "a dict-like object whose value should be equal to the key " + "attribute of the created instance." + ) + + @mock.patch('django.utils.timezone.now') + def test_create(self, mock_now): + """ + The test. + + Preparation: + - Optionally, authenticate client. + + Execution: + - HTTP POST request on model url. Payload from `create_data`. + + Check: + - Response status code: 201. + - Instance has been created in DB. + - Instance created is as expected (check with `create_expected`). + - Instance is correctly serialized (in response' data). + + """ + mock_now.return_value = self.now + + # Call API to create an instance of model. + user = self.get_user('create') + if user: + self.client.force_authenticate(user) + + r = self.client.post(self.get_url_model(), self.create_data) + + # Check database. + instances = self.model.objects.all() + self.assertEqual(len(instances), 1) + + instance = instances[0] + self.assertInstanceEqual(instance, self.create_expected) + + # Check API response. + self.assertEqual(r.status_code, status.HTTP_201_CREATED) + self.assertDictEqual(r.data, self.get_expected_data(instance)) + + +class RetrieveAPITestCaseMixin: + """ + Mixin to test the "Retrieve" API request of a model object. + + Authentication: + This operation use the label 'retrieve'. + See `GenericAPITestCaseMixin`#`Authenticate requests` for further + informations. + + """ + + @mock.patch('django.utils.timezone.now') + def test_retrieve(self, mock_now): + """ + The test. + + Preparation: + - Create a model instance from `instance_data` property. + - Optionally, authenticate client. + + Execution: + - Get url of the object with its pk. + - HTTP GET request on this url. + + Check: + - Response status code: 200. + - Instance is correctly serialized (in response' data). + + """ + mock_now.return_value = self.now + + # Setup database. + data = self.instance_data + instance = self.db_create(data) + + # Call API to retrieve the event data. + user = self.get_user('retrieve') + if user: + self.client.force_authenticate(user) + + r = self.client.get(self.get_url_object(pk=1)) + + # Check API response. + self.assertEqual(r.status_code, status.HTTP_200_OK) + self.assertDictEqual(r.data, self.get_expected_data(instance)) + + +class UpdateAPITestCaseMixin: + """ + Mixin to test the "Update" API request of a model. + + Authentication: + This operation use the label 'update'. + See `GenericAPITestCaseMixin`#`Authenticate requests` for further + informations. + + Notes: + * A single test using partial update / PATCH HTTP method is written. + * The request is sent assuming the payload is JSON. + + Todo: + * Add test for update / PUT HTTP method. + + """ + + @property + def update_data(self): + """ + Payload of the update request sent to the API. + """ + raise NotImplementedError( + "Subclass must define a update_data attribute/property. This is " + "the payload of the PUT request to the instance url." + ) + + @property + def update_expected(self): + """ + Data of model instance which should be updated. + """ + raise NotImplementedError( + "Subclass must define a update_expected attribute/property. It is " + "a dict-like object whose value should be equal to the key " + "attribute of the updated instance." + ) + + @mock.patch('django.utils.timezone.now') + def test_update(self, mock_now): + """ + The test. + + Preparation: + - Create a model instance from `instance_data` property. + - Optionally, authenticate client. + + Execution: + - Get url of the object with its pk. + - HTTP PATCH request on this url. Payload from `update_data`. + + Check: + - Response status code: 200. + - No instance has been created or deleted in DB. + - Updated instance is as expected (check with `update_expected`). + - Instance is correctly serialized (in response' data). + + """ + mock_now.return_value = self.now + + # Setup database. + data = self.instance_data + instance = self.db_create(data) + + # Call API to update the event. + user = self.get_user('update') + if user: + self.client.force_authenticate(user) + + r = self.client.patch(self.get_url_object(pk=1), self.update_data) + + # Check database. + instances = self.model.objects.all() + self.assertEqual(len(instances), 1) + + instance.refresh_from_db() + self.assertInstanceEqual(instance, self.update_expected) + + # Check API response. + self.assertEqual(r.status_code, status.HTTP_200_OK) + self.assertDictEqual(r.data, self.get_expected_data(instance)) + + +class DestroyAPITestCaseMixin: + """ + Mixin to test the "Destroy" API request of a model. + + Authentication: + This operation use the label 'destroy'. + See `GenericAPITestCaseMixin`#`Authenticate requests` for further + informations. + + """ + + @mock.patch('django.utils.timezone.now') + def test_destroy(self, mock_now): + """ + The test. + + Preparation: + - Create a model instance from `instance_data` property. + - Optionally, authenticate client. + + Execution: + - Get url of the object with its pk. + - HTTP DELETE request on this url. + + Check: + - Response status code: 204. + - Instance is no longer present in DB. + + """ + mock_now.return_value = self.now + + # Setup database. + data = self.instance_data + instance = self.db_create(data) + + # Call API to update the event. + user = self.get_user('destroy') + if user: + self.client.force_authenticate(user) + + r = self.client.delete(self.get_url_object(pk=1)) + + # Check database. + instances = self.model.objects.all() + self.assertEqual(len(instances), 0) + + with self.assertRaises(self.model.DoesNotExist): + instance.refresh_from_db() + + # Check API response. + self.assertEqual(r.status_code, status.HTTP_204_NO_CONTENT) + self.assertIsNone(r.data) + + +class ModelAPITestCaseMixin( + ListAPITestCaseMixin, CreateAPITestCaseMixin, RetrieveAPITestCaseMixin, + UpdateAPITestCaseMixin, DestroyAPITestCaseMixin, + GenericAPITestCaseMixin, + ): + """ + Tests all LCRUD operations on a model. + + See general docs in GenericAPITestCaseMixin. + See docs for a specific operation in [Operation]APITestCaseMixin. + + Example: + + class EventAPITestCase(ModelAPITestCaseMixin, APITestCase): + model = Event + ... + + @property + def create_data(self): + ... + + @property + def create_expected(self): + ... + + ... + + If you want to run only a few operations, consider using + specific-mixins individually. You can still have something as + `EventAPITestCaseMixin` to provide common atributes/properties/methods. + + """ + pass diff --git a/api/test/utils.py b/api/test/utils.py new file mode 100644 index 0000000..d6778a3 --- /dev/null +++ b/api/test/utils.py @@ -0,0 +1,19 @@ +import datetime + + +def _json_format(value): + if isinstance(value, datetime.datetime): + return value.isoformat().replace('+00:00', 'Z') + return value + + +def json_format(to_format): + """ + Returns value formatted like json output of the API. + + Supported type of value: + * datetime + """ + if type(to_format) == dict: + return {key: _json_format(value) for key, value in to_format.items()} + return _json_format(to_format) diff --git a/api/testcases.py b/api/testcases.py deleted file mode 100644 index d91f3cb..0000000 --- a/api/testcases.py +++ /dev/null @@ -1,292 +0,0 @@ -from datetime import timedelta -from django.contrib.auth import get_user_model -from django.db.models import Q -from django.utils import timezone -from django.urls import reverse - -from rest_framework.test import APIRequestFactory -from rest_framework import status - -from event.models import Event, Place, ActivityTag, ActivityTemplate - - -User = get_user_model() - - -class DataBaseMixin(object): - """ - provides a datatabse for API tests - """ - @classmethod - def setUpTestData(cls): - # Users - cls.user1_data = {'username': "user1", 'password': "pass1"} - cls.user1 = User.objects.create_user(**cls.user1_data) - - def setUp(self): - # Events - self.event1_data = {'title': "event1", 'slug': "slug1", - 'beginning_date': timezone.now() - + timedelta(days=30), - "description": "C'est trop cool !", - 'ending_date': timezone.now()+timedelta(days=31), - 'created_by': self.user1, } - self.event2_data = {"title": "test event", "slug": "test-event", - "description": "C'est trop cool !", - "beginning_date": "2017-07-18T18:05:00Z", - "ending_date": "2017-07-19T18:05:00Z", } - self.event1 = Event.objects.create(**self.event1_data) - - # ActivityTags - self.tag1_data = {"name": "tag1", "is_public": False, "color": "#111"} - self.tag2_data = {"name": "tag2", "is_public": False, "color": "#222"} - self.tag3_data = {"name": "tag3", "is_public": False, "color": "#333"} - self.tag1 = ActivityTag.objects.create(**self.tag1_data) - - self.act_temp1_data = {'title': "act temp1", 'is_public': True, - 'remarks': "test remark", 'event': self.event1} - self.act_temp2_data = {'title': "act temp2", 'is_public': False, - 'remarks': "test remark", - 'tags': [self.tag2_data, ]} - self.act_temp1 = ActivityTemplate.objects.create(**self.act_temp1_data) - self.act_temp1.tags.add(self.tag1) - - -class EventBasedModelTestMixin(DataBaseMixin): - """ - Note : need to define : - `model`: the model served by the API - `base_name`: the base_name used in the URL - `initial_count`: (will disappear) inital count in the db - `data_creation`: name in db used to create new instance - `instance_name`: existing instance name in the db - used for update/delete - `field_tested`: name of field tested in the update test - `serializer`: serialiser used for the API - - tests for models served by the API that are related to an event - and whose API urls are nested under ../event//%model - """ - def user_create_extra(self): - """ - extra test in creation by a permited user - """ - pass - - def pre_update_extra(self, data): - """ - extra modification for the data sent for update - """ - return data - - def post_update_extra(self): - """ - extra test for updated model - """ - pass - - def test_user_create(self): - """ - ensure a permited user can create a new %model object using API - """ - data = getattr(self, self.data_creation) - - event_id = self.event1.id - url = reverse('{base_name}-list'.format(base_name=self.base_name), - kwargs={'event_pk': event_id}) - self.client.force_authenticate(user=self.user1) - response = self.client.post(url, data, - format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(self.model.objects.count(), self.initial_count + 1) - self.assertEqual(self.model.objects.get(id=self.initial_count+1).event, - self.event1) - - self.user_create_extra() - - def test_user_update(self): - """ - ensure a permited user can update a new %model object using API - """ - instance = getattr(self, self.instance_name) - factory = APIRequestFactory() - - instance_id = instance.id - event_id = self.event1.id - url = reverse('{base_name}-list'.format(base_name=self.base_name), - kwargs={'event_pk': event_id}) - url = "%s%d/" % (url, instance_id) - - request = factory.get(url) - data = self.serializer(instance, context={'request': request}).data - - newvalue = "I'm a test" - data[self.field_tested] = newvalue - - data = self.pre_update_extra(data) - - self.client.force_authenticate(user=self.user1) - response = self.client.patch(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - instance.refresh_from_db() - self.assertEqual(getattr(instance, self.field_tested), newvalue) - - self.post_update_extra(instance) - - def test_user_delete(self): - """ - ensure a permited user can delete a new %model object using API - """ - instance = getattr(self, self.instance_name) - - instance_id = instance.id - event_id = self.event1.id - url = reverse('{base_name}-list'.format(base_name=self.base_name), - kwargs={'event_pk': event_id}) - url = "%s%d/" % (url, instance_id) - - self.client.force_authenticate(user=self.user1) - response = self.client.delete(url) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(self.model.objects.count(), self.initial_count - 1) - - -# TODO rajouter la gestion des permissions dans le Mixin -# TODO rajouter un test pour s'assurer que les personnes non -# connectées ne peuvent pas create/update/delete -class EventSpecificTestMixin(object): - """ - Tests is the EventSpecifics querysets are rendered correctly - using the API - Note : need to define : - `model`: the concerned model serve by the API - `root_base_name`: the base_name used in the root-level urls - `event_base_name`: the base_name used in the event-level urls - - tests for models served by the API that inherit EventSpecificMixin - """ - @classmethod - def setUpTestData(cls): - # Users - cls.user1_data = {'username': "user1", 'password': "pass1"} - cls.user1 = User.objects.create_user(**cls.user1_data) - # Events - cls.event1_data = {'title': "event1", 'slug': "slug1", - 'beginning_date': timezone.now() - + timedelta(days=30), - 'ending_date': timezone.now()+timedelta(days=31), - 'created_by': cls.user1, } - cls.event1 = Event.objects.create(**cls.event1_data) - - def setUp(self): - # Tag - self.tag_root_data = {"name": "tag2", "is_public": False, - "color": "#222"} - self.tag_event_data = {"name": "tag3", "is_public": False, - "color": "#333", 'event': self.event1} - self.tag_root = ActivityTag.objects.create(**self.tag_root_data) - self.tag_event = ActivityTag.objects.create(**self.tag_event_data) - - # Places - self.place_root_data = {'name': "place1", 'event': None, } - self.place_event_data = {'name': "place2", 'event': self.event1, } - self.place_root = Place.objects.create(**self.place_root_data) - self.place_event = Place.objects.create(**self.place_event_data) - - def test_lists(self): - """ - ensure that only root-level models are served under - api/%root_base_name/ - and that root-level and event-level models are served under - api/event//%event_base_name/ - """ - event_id = self.event1.id - root_count = self.model.objects.filter(event=None).count() - event_count = (self.model.objects - .filter(Q(event=self.event1) | Q(event=None)).count()) - - self.client.force_authenticate(user=self.user1) - - url = reverse('{base}-list'.format(base=self.root_base_name)) - response = self.client.get(url, format='json') - self.assertEqual(response.json()['count'], root_count) - - event_id = self.event1.id - url = reverse('{base}-list'.format(base=self.event_base_name), - kwargs={'event_pk': event_id}) - response = self.client.get(url, format='json') - self.assertEqual(response.json()['count'], event_count) - - -# TODO rajouter la gestion des permissions dans le Mixin -# TODO rajouter un test pour s'assurer que les personnes non -# connectées ne peuvent pas create/update/delete -# TODO? essayer de factoriser avec EventBasedMixin ? -# FIXME not working, peut être que le problème vient -# du fait que les dates sont mal envoyées dans le data ? A voir. -class ModelTestMixin(DataBaseMixin): - """ - Note : need to define : `model`, `base_name`, - `instance_name`, `field_tested`, `serializer` - - generic mixin for testing creation/update/delete - of models served by the API - """ - def test_user_create(self): - """ - ensure a permited user can create a new %model object using API - """ - data = getattr(self, self.data_creation) - initial_count = self.model.objects.count() - - url = reverse('{base_name}-list'.format(base_name=self.base_name)) - self.client.force_authenticate(user=self.user1) - response = self.client.post(url, data) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(self.model.objects.count(), initial_count + 1) - - instance = self.model.objects.get(id=initial_count+1) - for field in self.tested_fields.keys(): - self.assertEqual(getattr(instance, field), data[field]) - - def test_user_update(self): - """ - ensure a permited user can update a new %model object using API - """ - instance = getattr(self, self.instance_name) - factory = APIRequestFactory() - - instance_id = instance.id - url = reverse('{base_name}-detail'.format(base_name=self.base_name), - kwargs={'pk': instance_id}) - - request = factory.get(url) - data = self.serializer(instance, context={'request': request}).data - for field, value in self.tested_fields.items(): - data[field] = value - - self.client.force_authenticate(user=self.user1) - response = self.client.put(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - instance.refresh_from_db() - for field, value in self.tested_fields.items(): - self.assertEqual(getattr(instance, field), value) - - def test_user_delete(self): - """ - ensure a permited user can delete a new %model object using API - """ - instance = getattr(self, self.instance_name) - initial_count = self.model.objects.count() - - instance_id = instance.id - url = reverse('{base_name}-detail'.format(base_name=self.base_name), - kwargs={'pk': instance_id}) - - self.client.force_authenticate(user=self.user1) - response = self.client.delete(url) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(self.model.objects.count(), initial_count - 1) diff --git a/evenementiel/settings/common.py b/evenementiel/settings/common.py index 8baa801..6d41864 100644 --- a/evenementiel/settings/common.py +++ b/evenementiel/settings/common.py @@ -78,6 +78,7 @@ REST_FRAMEWORK = { 'rest_framework.permissions.AllowAny', ], 'PAGE_SIZE': 10, + 'TEST_REQUEST_DEFAULT_FORMAT': 'json', } ROOT_URLCONF = 'evenementiel.urls' From f33028892f6eea62543cf9f990676b837a40a7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 3 Aug 2017 12:21:37 +0200 Subject: [PATCH 6/7] Add API tests for Event/Place/ActivityTag/ActivityTemplate models This tests mainly cover cases whose API user is friendly. Should be extended with illegal things. ActivityTag, ActivityTemplate and Place models got tests for root and specific-event instance. --- api/event/tests.py | 566 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 533 insertions(+), 33 deletions(-) diff --git a/api/event/tests.py b/api/event/tests.py index 147b185..ed748e1 100644 --- a/api/event/tests.py +++ b/api/event/tests.py @@ -1,56 +1,556 @@ +from datetime import datetime, timedelta + from django.contrib.auth import get_user_model +from django.utils import timezone from rest_framework.test import APITestCase from event.models import Event, Place, ActivityTag, ActivityTemplate -from api.event.serializers import ActivityTemplateSerializer, EventSerializer -from api.testcases import EventBasedModelTestMixin, EventSpecificTestMixin,\ - ModelTestMixin +from api.test.testcases import ModelAPITestCaseMixin +from api.test.utils import json_format User = get_user_model() -class EventTest(ModelTestMixin, APITestCase): +class EventAPITests(ModelAPITestCaseMixin, APITestCase): model = Event - base_name = 'event' - tested_fields = {'title': "I'm a test", } - # Création - data_creation = 'event2_data' - # Update/Delete - instance_name = 'event1' - serializer = EventSerializer + url_label = 'event' + + list_ordering = 'beginning_date' + + @property + def user_auth_mapping(self): + return { + 'create': self.user, + } + + def get_expected_data(self, instance): + return json_format({ + 'url': ( + 'http://testserver/api/event/{pk}/' + .format(pk=instance.pk) + ), + 'id': instance.id, + 'title': instance.title, + 'slug': instance.slug, + 'description': instance.description, + 'beginning_date': instance.beginning_date, + 'ending_date': instance.ending_date, + 'created_by': instance.created_by.get_full_name(), + 'created_at': self.now, + }) + + @property + def instances_data(self): + return [ + { + 'title': "A first event", + 'slug': 'first-event', + 'description': "It's the first event.", + 'beginning_date': self.now + timedelta(days=100), + 'ending_date': self.now + timedelta(days=120), + 'created_by': self.user, + }, + { + 'title': "Another event", + 'slug': 'another-event', + 'description': "It's another event.", + 'beginning_date': self.now + timedelta(days=50), + 'ending_date': self.now + timedelta(days=60), + 'created_by': self.user, + }, + ] + + @property + def create_data(self): + return { + 'title': "An Event", + 'slug': 'event', + 'description': "I am an event.", + 'beginning_date': '2017-08-10 05:30:00', + 'ending_date': '2017-10-21 17:16:20', + } + + @property + def create_expected(self): + return { + **self.create_data, + 'beginning_date': timezone.make_aware( + datetime(2017, 8, 10, 5, 30, 0)), + 'ending_date': timezone.make_aware( + datetime(2017, 10, 21, 17, 16, 20)), + 'id': 1, + 'created_by': self.user, + 'created_at': self.now, + } + + @property + def update_data(self): + return { + 'title': "An updated event.", + 'slug': 'first-event', + 'description': "It's the first updated event.", + 'beginning_date': '2017-08-10 05:30:00', + 'ending_date': '2017-10-21 17:16:20', + 'created_by': 1, + } + + @property + def update_expected(self): + return { + **self.update_data, + 'beginning_date': timezone.make_aware( + datetime(2017, 8, 10, 5, 30, 0)), + 'ending_date': timezone.make_aware( + datetime(2017, 10, 21, 17, 16, 20)), + 'id': 1, + 'created_by': self.user, + 'created_at': self.now, + } -class ActivityTemplateTest(EventBasedModelTestMixin, APITestCase): +class ActivityTemplateAPITests(ModelAPITestCaseMixin, APITestCase): model = ActivityTemplate - base_name = 'event-activitytemplate' - initial_count = 1 - # Creation - data_creation = 'act_temp2_data' - # Update/Delete - instance_name = 'act_temp1' - field_tested = 'title' - serializer = ActivityTemplateSerializer + url_label = 'activitytemplate' - def test_create_extra(self): - self.assertEqual(self.model.objects.get(id=1).tags.count(), 1) + list_ordering = 'title' - def pre_update_extra(self, data): - data['tags'].append(self.tag2_data) + def setUp(self): + super().setUp() + + self.event = Event.objects.create(**{ + 'title': "event1", + 'slug': "slug1", + 'beginning_date': self.now + timedelta(days=30), + 'description': "C'est trop cool !", + 'ending_date': self.now + timedelta(days=31), + 'created_by': self.user, + }) + + self.activity_tag = ActivityTag.objects.create(**{ + 'name': "tag2", + 'is_public': False, + 'color': "#222", + }) + + def get_url_model(self, *args, **kwargs): + kwargs['event_pk'] = 1 + return super().get_url_model(*args, **kwargs) + + def get_url_object(self, *args, **kwargs): + kwargs['event_pk'] = 1 + return super().get_url_object(*args, **kwargs) + + def get_expected_data(self, instance): + return json_format({ + 'url': ( + 'http://testserver/api/event/{event_pk}/template/{pk}/' + .format(event_pk=instance.event.pk, pk=instance.pk) + ), + 'id': instance.id, + 'title': instance.title, + 'is_public': instance.is_public, + 'remarks': instance.remarks, + 'event': instance.event.pk, + 'tags': [ + { + **(tag.event and { + 'url': ( + 'http://testserver/api/event/{event_pk}/' + 'tag/{tag_pk}/' + .format(event_pk=tag.event.pk, tag_pk=tag.pk) + ), + 'event': tag.event.pk, + } or { + 'url': ( + 'http://testserver/api/tag/{tag_pk}/' + .format(tag_pk=tag.pk) + ), + 'event': None, + }), + 'id': tag.id, + 'name': tag.name, + 'is_public': tag.is_public, + 'color': tag.color, + } + for tag in instance.tags.all() + ], + 'has_perm': instance.has_perm, + 'min_perm': instance.min_perm, + 'max_perm': instance.max_perm, + 'description': instance.description, + }) + + @property + def instances_data(self): + return [ + { + 'title': "act temp1", + 'is_public': True, + 'remarks': "test remark", + 'event': self.event, + }, + { + 'title': "act temp2", + 'is_public': False, + 'remarks': "test remark", + 'event': self.event, + 'tags': [self.activity_tag] + }, + ] + + @property + def create_data(self): + return { + 'title': "act temp2", + 'is_public': False, + 'remarks': "test remark", + 'tags': [ + { + 'name': "tag2", + 'is_public': False, + 'color': '#222', + }, + ], + } + + @property + def create_expected(self): + return { + **self.create_data, + 'tags': [ActivityTag.objects.get(name='tag2')], + } + + @property + def update_data(self): + return { + 'title': "act temp3", + 'is_public': False, + 'remarks': "another test remark", + 'tags': [ + { + 'name': "tag2", + 'is_public': False, + 'color': '#222', + }, + { + 'name': "body", + 'is_public': True, + 'color': '#555', + }, + ], + } + + @property + def update_expected(self): + tag_root = ActivityTag.objects.get(name='tag2') + self.assertIsNone(tag_root.event) + + tag_bound = ActivityTag.objects.get(name='body') + self.assertEqual(tag_bound.event, self.event) + + return { + 'title': "act temp3", + 'is_public': False, + 'remarks': "another test remark", + 'tags': [tag_root, tag_bound] + } + + +class BaseActivityTagAPITests: + model = ActivityTag + url_label = 'activitytag' + + list_ordering = ('is_public', 'name') + + def setUp(self): + super().setUp() + + self.event = Event.objects.create(**{ + 'title': "event1", + 'slug': "slug1", + 'beginning_date': self.now + timedelta(days=30), + 'description': "C'est trop cool !", + 'ending_date': self.now + timedelta(days=31), + 'created_by': self.user, + }) + + def get_expected_data(self, instance): + return { + **(instance.event and { + 'url': ( + 'http://testserver/api/event/{event_pk}/tag/{pk}/' + .format(event_pk=instance.event.pk, pk=instance.pk) + ), + 'event': instance.event.pk, + } or { + 'url': ( + 'http://testserver/api/tag/{pk}/' + .format(pk=instance.pk) + ), + 'event': None, + }), + 'id': instance.id, + 'name': instance.name, + 'is_public': instance.is_public, + 'color': instance.color, + } + + @property + def instances_data(self): + return [ + { + 'name': 'a tag', + 'is_public': False, + 'color': '#222', + 'event': None, + }, + { + 'name': 'another tag', + 'is_public': True, + 'color': '#555', + 'event': self.event, + } + ] + + @property + def create_data(self): + return { + 'name': 'plop tag', + 'is_public': True, + 'color': '#888999', + } + + @property + def update_data(self): + return { + 'name': 'this is the tag', + 'is_public': True, + 'color': '#333', + } + + +class RootActivityTagAPITests( + BaseActivityTagAPITests, + ModelAPITestCaseMixin, + APITestCase + ): + + @property + def list_expected(self): + return [ActivityTag.objects.get(name='a tag')] + + @property + def create_expected(self): + return { + **self.create_data, + 'event': None, + } + + @property + def instance_data(self): + data = self.instances_data[0] + self.assertIsNone( + data['event'], + msg="This test should use a tag unbound to any event.", + ) return data - def post_update_extra(self, instance): - self.assertEqual(instance.tags.count(), 2) + @property + def update_expected(self): + return { + **self.update_data, + 'event': None, + } -class EventSpecficTagTest(EventSpecificTestMixin, APITestCase): - model = ActivityTag - root_base_name = 'activitytag' - event_base_name = 'event-activitytag' +class EventActivityTagAPITests( + BaseActivityTagAPITests, + ModelAPITestCaseMixin, + APITestCase + ): + + def get_url_model(self, *args, **kwargs): + kwargs['event_pk'] = 1 + return super().get_url_model(*args, **kwargs) + + def get_url_object(self, *args, **kwargs): + kwargs['event_pk'] = 1 + return super().get_url_object(*args, **kwargs) + + @property + def list_expected(self): + return [ + ActivityTag.objects.get(name='a tag'), + ActivityTag.objects.get(name='another tag'), + ] + + @property + def create_expected(self): + return { + **self.create_data, + 'event': self.event, + } + + @property + def instance_data(self): + data = self.instances_data[1] + self.assertIsNotNone( + data['event'], + msg="This test should use an event-bound tag.", + ) + return data + + @property + def update_expected(self): + return { + **self.update_data, + 'event': self.event, + } -class EventSpecficPlaceTest(EventSpecificTestMixin, APITestCase): +class BasePlaceAPITests: model = Place - root_base_name = 'place' - event_base_name = 'event-place' + url_label = 'place' + + list_ordering = 'name' + + def setUp(self): + super().setUp() + + self.event = Event.objects.create(**{ + 'title': "event1", + 'slug': "slug1", + 'beginning_date': self.now + timedelta(days=30), + 'description': "C'est trop cool !", + 'ending_date': self.now + timedelta(days=31), + 'created_by': self.user, + }) + + def get_expected_data(self, instance): + return { + **(instance.event and { + 'url': ( + 'http://testserver/api/event/{event_pk}/place/{pk}/' + .format(event_pk=instance.event.pk, pk=instance.pk) + ), + 'event': instance.event.pk, + } or { + 'url': ( + 'http://testserver/api/place/{pk}/' + .format(pk=instance.pk) + ), + 'event': None, + }), + 'id': instance.id, + 'name': instance.name, + 'description': instance.description, + } + + @property + def instances_data(self): + return [ + { + 'name': 'a place', + 'event': None, + 'description': 'a description', + }, + { + 'name': 'another place', + 'event': self.event, + } + ] + + @property + def create_data(self): + return { + 'name': 'plop place', + 'description': 'the couro is a chill place', + } + + @property + def update_data(self): + return { + 'name': 'this is the place', + 'description': 'to be', + } + + +class RootPlaceAPITests( + BasePlaceAPITests, + ModelAPITestCaseMixin, + APITestCase + ): + + @property + def list_expected(self): + return [Place.objects.get(name='a place')] + + @property + def create_expected(self): + return { + **self.create_data, + 'event': None, + } + + @property + def instance_data(self): + data = self.instances_data[0] + self.assertIsNone( + data['event'], + msg="This test should use a place unbound to any event.", + ) + return data + + @property + def update_expected(self): + return { + **self.update_data, + 'event': None, + } + + +class EventPlaceTagAPITests( + BasePlaceAPITests, + ModelAPITestCaseMixin, + APITestCase + ): + + def get_url_model(self, *args, **kwargs): + kwargs['event_pk'] = 1 + return super().get_url_model(*args, **kwargs) + + def get_url_object(self, *args, **kwargs): + kwargs['event_pk'] = 1 + return super().get_url_object(*args, **kwargs) + + @property + def list_expected(self): + return [ + Place.objects.get(name='a place'), + Place.objects.get(name='another place'), + ] + + @property + def create_expected(self): + return { + **self.create_data, + 'event': self.event, + } + + @property + def instance_data(self): + data = self.instances_data[1] + self.assertIsNotNone( + data['event'], + msg="This test should use an event-bound place.", + ) + return data + + @property + def update_expected(self): + return { + **self.update_data, + 'event': self.event, + } From 18037246e14ba0977dba9ea2add9147ea0af08f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 12 Aug 2017 14:54:45 +0200 Subject: [PATCH 7/7] Remake migrations --- equipment/migrations/0001_initial.py | 4 +- .../migrations/0002_auto_20170802_2323.py | 21 --- event/migrations/0001_initial.py | 52 +++---- event/migrations/0002_auto_20170723_1419.py | 21 --- event/migrations/0003_auto_20170726_1116.py | 26 ---- event/migrations/0004_auto_20170802_2323.py | 136 ------------------ 6 files changed, 28 insertions(+), 232 deletions(-) delete mode 100644 equipment/migrations/0002_auto_20170802_2323.py delete mode 100644 event/migrations/0002_auto_20170723_1419.py delete mode 100644 event/migrations/0003_auto_20170726_1116.py delete mode 100644 event/migrations/0004_auto_20170802_2323.py diff --git a/equipment/migrations/0001_initial.py b/equipment/migrations/0001_initial.py index 00e5083..5e042ff 100644 --- a/equipment/migrations/0001_initial.py +++ b/equipment/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-21 14:20 +# Generated by Django 1.11.4 on 2017-08-12 12:47 from __future__ import unicode_literals from django.db import migrations, models @@ -65,6 +65,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='equipment', name='event', - field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), + field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), ), ] diff --git a/equipment/migrations/0002_auto_20170802_2323.py b/equipment/migrations/0002_auto_20170802_2323.py deleted file mode 100644 index 55a2402..0000000 --- a/equipment/migrations/0002_auto_20170802_2323.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-02 23:23 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('equipment', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='equipment', - name='event', - field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), - ), - ] diff --git a/event/migrations/0001_initial.py b/event/migrations/0001_initial.py index 79dfb71..2c8ea07 100644 --- a/event/migrations/0001_initial.py +++ b/event/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-21 14:20 +# Generated by Django 1.11.4 on 2017-08-12 12:47 from __future__ import unicode_literals from django.conf import settings @@ -22,12 +22,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(blank=True, max_length=200, null=True, verbose_name="nom de l'activité")), - ('is_public', models.NullBooleanField()), - ('has_perm', models.NullBooleanField()), + ('is_public', models.NullBooleanField(verbose_name='est public')), + ('has_perm', models.NullBooleanField(verbose_name='inscription de permanents')), ('min_perm', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='nombre minimum de permanents')), ('max_perm', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='nombre maximum de permanents')), - ('description', models.TextField(blank=True, help_text='Public, Visible par tout le monde.', null=True, verbose_name='description')), - ('remarks', models.TextField(blank=True, help_text='Visible uniquement par les organisateurs', null=True, verbose_name='remarques')), + ('description', models.TextField(blank=True, help_text="Visible par tout le monde si l'événément est public.", null=True, verbose_name='description')), + ('remarks', models.TextField(blank=True, help_text='Visible uniquement par les organisateurs.', null=True, verbose_name='remarques')), ('beginning', models.DateTimeField(verbose_name='heure de début')), ('end', models.DateTimeField(verbose_name='heure de fin')), ], @@ -41,8 +41,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=200, verbose_name='nom du tag')), - ('is_public', models.BooleanField(help_text="Sert à faire une distinction dans l'affichage selon que cela soit destiné au public ou à l'équipe organisatrice")), - ('color', models.CharField(help_text='Rentrer une couleur en hexadécimal', max_length=7, validators=[django.core.validators.RegexValidator(message="La chaîne de caractère rentrée n'est pas une couleur en hexadécimal.", regex='^#(?:[0-9a-fA-F]{3}){1,2}$')], verbose_name='couleur')), + ('is_public', models.BooleanField(help_text="Sert à faire une distinction dans l'affichage selon que le tag soit destiné au public ou à l'organisation.", verbose_name='est public')), + ('color', models.CharField(help_text='Rentrer une couleur en hexadécimal (#XXX ou #XXXXXX).', max_length=7, validators=[django.core.validators.RegexValidator(message="La chaîne de caractère rentrée n'est pas une couleur en hexadécimal.", regex='^#(?:[0-9a-fA-F]{3}){1,2}$')], verbose_name='couleur')), ], options={ 'verbose_name': 'tag', @@ -54,12 +54,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(blank=True, max_length=200, null=True, verbose_name="nom de l'activité")), - ('is_public', models.NullBooleanField()), - ('has_perm', models.NullBooleanField()), + ('is_public', models.NullBooleanField(verbose_name='est public')), + ('has_perm', models.NullBooleanField(verbose_name='inscription de permanents')), ('min_perm', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='nombre minimum de permanents')), ('max_perm', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='nombre maximum de permanents')), - ('description', models.TextField(blank=True, help_text='Public, Visible par tout le monde.', null=True, verbose_name='description')), - ('remarks', models.TextField(blank=True, help_text='Visible uniquement par les organisateurs', null=True, verbose_name='remarques')), + ('description', models.TextField(blank=True, help_text="Visible par tout le monde si l'événément est public.", null=True, verbose_name='description')), + ('remarks', models.TextField(blank=True, help_text='Visible uniquement par les organisateurs.', null=True, verbose_name='remarques')), ], options={ 'verbose_name': 'template activité', @@ -71,12 +71,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=200, verbose_name="nom de l'évènement")), - ('slug', models.SlugField(help_text="Seulement des lettres, des chiffres oules caractères '_' ou '-'.", unique=True, verbose_name='identificateur')), - ('creation_date', models.DateTimeField(auto_now_add=True, verbose_name='date de création')), + ('slug', models.SlugField(help_text="Seulement des lettres, des chiffres ou les caractères '_' ou '-'.", unique=True, verbose_name='identificateur')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='date de création')), ('description', models.TextField(verbose_name='description')), ('beginning_date', models.DateTimeField(verbose_name='date de début')), ('ending_date', models.DateTimeField(verbose_name='date de fin')), - ('created_by', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='created_events', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL, verbose_name='créé par')), ], options={ 'verbose_name': 'évènement', @@ -89,7 +89,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=200, verbose_name='nom du lieu')), ('description', models.TextField(blank=True)), - ('event', models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement')), + ('event', models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement')), ], options={ 'verbose_name': 'lieu', @@ -99,46 +99,46 @@ class Migration(migrations.Migration): migrations.AddField( model_name='activitytemplate', name='event', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='event.Event'), + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), ), migrations.AddField( model_name='activitytemplate', - name='place', - field=models.ManyToManyField(blank=True, to='event.Place'), + name='places', + field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'), ), migrations.AddField( model_name='activitytemplate', name='tags', - field=models.ManyToManyField(blank=True, to='event.ActivityTag'), + field=models.ManyToManyField(blank=True, to='event.ActivityTag', verbose_name='tags'), ), migrations.AddField( model_name='activitytag', name='event', - field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), + field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), ), migrations.AddField( model_name='activity', name='event', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='event.Event'), + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), ), migrations.AddField( model_name='activity', name='parent', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='event.ActivityTemplate'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='event.ActivityTemplate', verbose_name='template'), ), migrations.AddField( model_name='activity', - name='place', - field=models.ManyToManyField(blank=True, to='event.Place'), + name='places', + field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'), ), migrations.AddField( model_name='activity', name='staff', - field=models.ManyToManyField(blank=True, related_name='in_perm_activities', to=settings.AUTH_USER_MODEL), + field=models.ManyToManyField(blank=True, related_name='in_perm_activities', to=settings.AUTH_USER_MODEL, verbose_name='permanents'), ), migrations.AddField( model_name='activity', name='tags', - field=models.ManyToManyField(blank=True, to='event.ActivityTag'), + field=models.ManyToManyField(blank=True, to='event.ActivityTag', verbose_name='tags'), ), ] diff --git a/event/migrations/0002_auto_20170723_1419.py b/event/migrations/0002_auto_20170723_1419.py deleted file mode 100644 index 954dc74..0000000 --- a/event/migrations/0002_auto_20170723_1419.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-23 14:19 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('event', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='activity', - name='parent', - field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='event.ActivityTemplate'), - ), - ] diff --git a/event/migrations/0003_auto_20170726_1116.py b/event/migrations/0003_auto_20170726_1116.py deleted file mode 100644 index e776702..0000000 --- a/event/migrations/0003_auto_20170726_1116.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.3 on 2017-07-26 11:16 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('event', '0002_auto_20170723_1419'), - ] - - operations = [ - migrations.RenameField( - model_name='event', - old_name='creation_date', - new_name='created_at', - ), - migrations.AlterField( - model_name='activity', - name='parent', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='event.ActivityTemplate'), - ), - ] diff --git a/event/migrations/0004_auto_20170802_2323.py b/event/migrations/0004_auto_20170802_2323.py deleted file mode 100644 index 22c4349..0000000 --- a/event/migrations/0004_auto_20170802_2323.py +++ /dev/null @@ -1,136 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-02 23:23 -from __future__ import unicode_literals - -from django.conf import settings -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('event', '0003_auto_20170726_1116'), - ] - - operations = [ - migrations.RemoveField( - model_name='activity', - name='place', - ), - migrations.RemoveField( - model_name='activitytemplate', - name='place', - ), - migrations.AddField( - model_name='activity', - name='places', - field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'), - ), - migrations.AddField( - model_name='activitytemplate', - name='places', - field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'), - ), - migrations.AlterField( - model_name='activity', - name='description', - field=models.TextField(blank=True, help_text="Visible par tout le monde si l'événément est public.", null=True, verbose_name='description'), - ), - migrations.AlterField( - model_name='activity', - name='event', - field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), - ), - migrations.AlterField( - model_name='activity', - name='has_perm', - field=models.NullBooleanField(verbose_name='inscription de permanents'), - ), - migrations.AlterField( - model_name='activity', - name='is_public', - field=models.NullBooleanField(verbose_name='est public'), - ), - migrations.AlterField( - model_name='activity', - name='parent', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='event.ActivityTemplate', verbose_name='template'), - ), - migrations.AlterField( - model_name='activity', - name='remarks', - field=models.TextField(blank=True, help_text='Visible uniquement par les organisateurs.', null=True, verbose_name='remarques'), - ), - migrations.AlterField( - model_name='activity', - name='staff', - field=models.ManyToManyField(blank=True, related_name='in_perm_activities', to=settings.AUTH_USER_MODEL, verbose_name='permanents'), - ), - migrations.AlterField( - model_name='activity', - name='tags', - field=models.ManyToManyField(blank=True, to='event.ActivityTag', verbose_name='tags'), - ), - migrations.AlterField( - model_name='activitytag', - name='color', - field=models.CharField(help_text='Rentrer une couleur en hexadécimal (#XXX ou #XXXXXX).', max_length=7, validators=[django.core.validators.RegexValidator(message="La chaîne de caractère rentrée n'est pas une couleur en hexadécimal.", regex='^#(?:[0-9a-fA-F]{3}){1,2}$')], verbose_name='couleur'), - ), - migrations.AlterField( - model_name='activitytag', - name='event', - field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), - ), - migrations.AlterField( - model_name='activitytag', - name='is_public', - field=models.BooleanField(help_text="Sert à faire une distinction dans l'affichage selon que le tag soit destiné au public ou à l'organisation.", verbose_name='est public'), - ), - migrations.AlterField( - model_name='activitytemplate', - name='description', - field=models.TextField(blank=True, help_text="Visible par tout le monde si l'événément est public.", null=True, verbose_name='description'), - ), - migrations.AlterField( - model_name='activitytemplate', - name='event', - field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), - ), - migrations.AlterField( - model_name='activitytemplate', - name='has_perm', - field=models.NullBooleanField(verbose_name='inscription de permanents'), - ), - migrations.AlterField( - model_name='activitytemplate', - name='is_public', - field=models.NullBooleanField(verbose_name='est public'), - ), - migrations.AlterField( - model_name='activitytemplate', - name='remarks', - field=models.TextField(blank=True, help_text='Visible uniquement par les organisateurs.', null=True, verbose_name='remarques'), - ), - migrations.AlterField( - model_name='activitytemplate', - name='tags', - field=models.ManyToManyField(blank=True, to='event.ActivityTag', verbose_name='tags'), - ), - migrations.AlterField( - model_name='event', - name='created_by', - field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL, verbose_name='créé par'), - ), - migrations.AlterField( - model_name='event', - name='slug', - field=models.SlugField(help_text="Seulement des lettres, des chiffres ou les caractères '_' ou '-'.", unique=True, verbose_name='identificateur'), - ), - migrations.AlterField( - model_name='place', - name='event', - field=models.ForeignKey(blank=True, help_text="Si spécifié, l'instance du modèle est spécifique à l'évènement en question.", null=True, on_delete=django.db.models.deletion.CASCADE, to='event.Event', verbose_name='évènement'), - ), - ]