diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/equipment/serializers.py b/api/equipment/serializers.py new file mode 100644 index 0000000..ef73251 --- /dev/null +++ b/api/equipment/serializers.py @@ -0,0 +1,7 @@ +from equipment.models import Equipment +from api.event.serializers import EventSpecificSerializer + + +# TODO : le faire +class EquipmentSerializer(EventSpecificSerializer): + pass diff --git a/api/event/__init__.py b/api/event/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..a7ff849 --- /dev/null +++ b/api/event/serializers.py @@ -0,0 +1,92 @@ +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.ModelSerializer): + # TODO: Change this to a nested serializer ~(url, full_name) of User + created_by = serializers.ReadOnlyField(source='created_by.get_full_name') + + class Meta: + model = Event + fields = ( + 'url', 'id', 'title', 'slug', 'created_by', 'created_at', + 'description', 'beginning_date', 'ending_date', + ) + + +# Serializers +# TODO rajouter des permissions +class PlaceSerializer(serializers.ModelSerializer): + serializer_url_field = EventHyperlinkedIdentityField + + class Meta: + model = Place + fields = ('url', 'id', 'name', 'description', 'event') + + +# TODO rajouter des permissions +class ActivityTagSerializer(serializers.ModelSerializer): + serializer_url_field = EventHyperlinkedIdentityField + + class Meta: + model = ActivityTag + fields = ('url', 'id', 'name', 'is_public', 'color', 'event') + + +# TODO rajouter des permissions +class ActivityTemplateSerializer(serializers.ModelSerializer): + tags = ActivityTagSerializer(many=True) + + serializer_url_field = EventHyperlinkedIdentityField + + class Meta: + model = ActivityTemplate + fields = ( + 'url', 'id', 'title', 'event', 'is_public', 'has_perm', 'min_perm', + 'max_perm', 'description', 'remarks', 'tags', + ) + + 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, _ = ActivityTag.objects.get_or_create(**tag_data, defaults={ + 'event': instance.event, + }) + tags.append(tag) + instance.tags.add(*tags) + + def create(self, 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().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 + + +class ActivitySerializer(serializers.ModelSerializer): + pass diff --git a/api/event/tests.py b/api/event/tests.py new file mode 100644 index 0000000..ed748e1 --- /dev/null +++ b/api/event/tests.py @@ -0,0 +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.test.testcases import ModelAPITestCaseMixin +from api.test.utils import json_format + +User = get_user_model() + + +class EventAPITests(ModelAPITestCaseMixin, APITestCase): + model = Event + 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 ActivityTemplateAPITests(ModelAPITestCaseMixin, APITestCase): + model = ActivityTemplate + url_label = 'activitytemplate' + + list_ordering = 'title' + + 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 + + @property + def update_expected(self): + return { + **self.update_data, + 'event': None, + } + + +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 BasePlaceAPITests: + model = 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, + } diff --git a/api/event/views.py b/api/event/views.py new file mode 100644 index 0000000..3497f3a --- /dev/null +++ b/api/event/views.py @@ -0,0 +1,121 @@ +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 event.models import Activity, ActivityTag, ActivityTemplate, Event, Place + +from .serializers import ( + ActivitySerializer, ActivityTagSerializer, ActivityTemplateSerializer, + EventSerializer, PlaceSerializer, +) + +User = get_user_model() + + +# classes utilitaires + +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 + * OR objects related to the specified event + AND root level objects + if an event is specified it passes the event_pk + 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 + """ + queryset = super().get_queryset() + filters = Q(event=None) + if self.event: + filters |= Q(event=self.event) + return queryset.filter(filters) + + +# ViewSets +class EventViewSet(ModelViewSet): + 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(EventSpecificModelViewSet): + queryset = Place.objects.all() + serializer_class = PlaceSerializer + + filter_backends = (OrderingFilter,) + ordering_fields = ('name', ) + ordering = ('name', ) + + +class ActivityTagViewSet(EventSpecificModelViewSet): + queryset = ActivityTag.objects.all() + serializer_class = ActivityTagSerializer + + filter_backends = (OrderingFilter,) + ordering_fields = ('is_public', 'name', ) + ordering = ('is_public', 'name', ) + + +class ActivityTemplateViewSet(EventModelViewSet): + queryset = ActivityTemplate.objects.all() + serializer_class = ActivityTemplateSerializer + + filter_backends = (OrderingFilter,) + ordering_fields = ('title', ) + ordering = ('title', ) + + +class ActivityViewSet(EventModelViewSet): + queryset = Activity.objects.all() + serializer_class = ActivitySerializer + + filter_backends = (OrderingFilter,) + ordering_fields = ('title', ) + ordering = ('title', ) 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/urls.py b/api/urls.py new file mode 100644 index 0000000..1a704a0 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,26 @@ +from django.conf.urls import url, include + +from rest_framework_nested.routers import SimpleRouter, NestedSimpleRouter + +from api.event import views + + +router = SimpleRouter() +router.register(r'event', views.EventViewSet) +router.register(r'place', views.PlaceViewSet) +router.register(r'tag', views.ActivityTagViewSet) + + +# Views behind /event//... +event_router = NestedSimpleRouter(router, r'event', lookup='event') +event_router.register(r'place', views.PlaceViewSet) +event_router.register(r'tag', views.ActivityTagViewSet) +event_router.register(r'template', views.ActivityTemplateViewSet) + + +# API URLconf: routers + auth for browsable API. +urlpatterns = [ + url(r'^', include(router.urls)), + url(r'^', include(event_router.urls)), + url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), +] diff --git a/api/users/__init__.py b/api/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/users/serializers.py b/api/users/serializers.py new file mode 100644 index 0000000..8b83167 --- /dev/null +++ b/api/users/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserMinimalSerializer(serializers.HyperlinkedModelSerializer): + """ + Déstiné à tout le monde (de connecté) + """ + + class Meta: + model = User + fields = ('url', 'id', 'first_name', 'last_name',) + + +class UserAdminSerializer(serializers.HyperlinkedModelSerializer): + """ + Déstiné à l'utilisat-rice-eur et aux administrat-rice-eur-s + """ + + class Meta: + model = User + fields = ('url', 'id', 'username', 'first_name', 'last_name', + 'email', 'phone', 'last_login', 'date_joined',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + """ + Déstiné aux utilisat-rice-eur-s dont l'utilisait-rice-eur-s en question + a laissé le droit d'y accéder, par exemple les participant-e-s + au même `event` que l'utilisat-rice-eur en question + """ + + class Meta: + model = User + fields = ('url', 'id', 'first_name', 'last_name', + 'email', 'phone',) diff --git a/api/users/views.py b/api/users/views.py new file mode 100644 index 0000000..e69de29 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/evenementiel/settings/common.py b/evenementiel/settings/common.py index ea71e41..6d41864 100644 --- a/evenementiel/settings/common.py +++ b/evenementiel/settings/common.py @@ -56,8 +56,10 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'channels', + 'rest_framework', 'bootstrapform', 'widget_tweaks', + 'api', ] MIDDLEWARE_CLASSES = [ @@ -71,6 +73,14 @@ MIDDLEWARE_CLASSES = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + 'PAGE_SIZE': 10, + 'TEST_REQUEST_DEFAULT_FORMAT': 'json', +} + ROOT_URLCONF = 'evenementiel.urls' STATIC_URL = "/static/" diff --git a/evenementiel/urls.py b/evenementiel/urls.py index e002332..ad68e7a 100644 --- a/evenementiel/urls.py +++ b/evenementiel/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^event/', include('event.urls')), url(r'^user/', include('users.urls')), + url(r'^api/', include('api.urls')), url(r'^', include('shared.urls')), ] 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/models.py b/event/models.py index 17060b2..c58b17f 100644 --- a/event/models.py +++ b/event/models.py @@ -1,29 +1,34 @@ 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 +from .validators import ColorValidator + User = get_user_model() 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, @@ -51,12 +56,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: @@ -65,9 +72,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: @@ -80,26 +87,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") @@ -111,49 +114,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 @@ -170,44 +177,46 @@ 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é") verbose_name_plural = _("activités") def __str__(self): - return self.get_herited('title') + return self.get_herited('title') \ No newline at end of file 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 ) 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." + ), +) diff --git a/requirements.txt b/requirements.txt index 41fb065..18efed2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,8 @@ Pillow channels django-bootstrap-form==3.2.1 django-widget-tweaks +djangorestframework==3.6.3 +drf-nested-routers==0.90.0 django-notifications django-contrib-comments diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29