Merge branch 'aureplop/serializers' into 'Qwann/Serializers'

Some serializers fix + base testcases + tests

See merge request !21
This commit is contained in:
Erkan Narmanli 2017-08-13 18:23:47 +02:00
commit 7d406f9370
20 changed files with 1560 additions and 644 deletions

0
api/__init__.py Normal file
View file

0
api/event/__init__.py Normal file
View file

33
api/event/fields.py Normal file
View file

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

View file

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

View file

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

View file

@ -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', )

694
api/test/testcases.py Normal file
View file

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

19
api/test/utils.py Normal file
View file

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

View file

@ -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/<event_id>/%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_id>/%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)

View file

@ -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_pk>/...
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')),
]

0
api/users/__init__.py Normal file
View file

View file

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

View file

@ -78,6 +78,7 @@ REST_FRAMEWORK = {
'rest_framework.permissions.AllowAny',
],
'PAGE_SIZE': 10,
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
}
ROOT_URLCONF = 'evenementiel.urls'

View file

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

View file

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

View file

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

View file

@ -1,36 +1,41 @@
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,
)
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 +50,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 +66,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 +81,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 +108,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 +171,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é")

View file

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

10
event/validators.py Normal file
View file

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

View file