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 rest_framework import serializers
from event.models import Event, ActivityTag, Place, ActivityTemplate from event.models import Event, ActivityTag, Place, ActivityTemplate
from .fields import EventHyperlinkedIdentityField
# Event Serializer # 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_by = serializers.ReadOnlyField(source='created_by.get_full_name')
created_at = serializers.ReadOnlyField()
class Meta: class Meta:
model = Event model = Event
fields = ('url', 'id', 'title', 'slug', 'created_by', 'created_at', fields = (
'description', 'beginning_date', 'ending_date') '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
# Serializers # Serializers
# TODO rajouter des permissions # TODO rajouter des permissions
class PlaceSerializer(EventSpecificSerializerMixin, class PlaceSerializer(serializers.ModelSerializer):
serializers.ModelSerializer): serializer_url_field = EventHyperlinkedIdentityField
class Meta: class Meta:
model = Place model = Place
@ -55,8 +32,9 @@ class PlaceSerializer(EventSpecificSerializerMixin,
# TODO rajouter des permissions # TODO rajouter des permissions
class ActivityTagSerializer(EventSpecificSerializerMixin, class ActivityTagSerializer(serializers.ModelSerializer):
serializers.ModelSerializer): serializer_url_field = EventHyperlinkedIdentityField
class Meta: class Meta:
model = ActivityTag model = ActivityTag
fields = ('url', 'id', 'name', 'is_public', 'color', 'event') fields = ('url', 'id', 'name', 'is_public', 'color', 'event')
@ -64,36 +42,30 @@ class ActivityTagSerializer(EventSpecificSerializerMixin,
# TODO rajouter des permissions # TODO rajouter des permissions
class ActivityTemplateSerializer(serializers.ModelSerializer): class ActivityTemplateSerializer(serializers.ModelSerializer):
event = EventSerializer(read_only=True)
tags = ActivityTagSerializer(many=True) tags = ActivityTagSerializer(many=True)
serializer_url_field = EventHyperlinkedIdentityField
class Meta: class Meta:
model = ActivityTemplate model = ActivityTemplate
fields = ('id', 'title', 'event', 'is_public', 'has_perm', fields = (
'min_perm', 'max_perm', 'description', 'remarks', 'tags',) 'url', 'id', 'title', 'event', 'is_public', 'has_perm', 'min_perm',
'max_perm', 'description', 'remarks', 'tags',
)
def update(self, instance, validated_data): def process_tags(self, instance, tags_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()
# TODO: en fonction de si backbone envoie un `id` ou non lorsque le tag # 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 # 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 # 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 # été modifié entre temps dans la base de donnée (mais pas sur la
# classe backbone # classe backbone
tags = []
for tag_data in tags_data: for tag_data in tags_data:
tag_data.pop('event', None) tag, _ = ActivityTag.objects.get_or_create(**tag_data, defaults={
tags = [ActivityTag.objects.get_or_create(event=event, **tag_data)[0] 'event': instance.event,
for tag_data in tags_data] })
instance.tags.set(tags) tags.append(tag)
return instance instance.tags.add(*tags)
def create(self, validated_data): 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éé les autres champs et si l'id n'existe pas on le créé
""" """
tags_data = validated_data.pop('tags') tags_data = validated_data.pop('tags')
event_pk = validated_data.pop('event_pk') activity_template = super().create(validated_data)
event = event_pk and get_object_or_404(Event, id=event_pk) or None self.process_tags(activity_template, tags_data)
activity_template = ActivityTemplate.objects.create(event=event, return activity_template
**validated_data)
# TODO: en fonction de si backbone envoie un `id` ou non lorsque le tag def update(self, instance, validated_data):
# 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 @tags comportement attendu : si l'id existe déjà on ne change pas
# été modifié entre temps dans la base de donnée (mais pas sur la les autres champs et si l'id n'existe pas on le créé
# classe backbone """
for tag_data in tags_data: tags_data = validated_data.pop('tags')
tag_data.pop('event', None) activity_template = super().update(instance, validated_data)
tags = [ActivityTag.objects.get_or_create(event=event, **tag_data)[0] self.process_tags(activity_template, tags_data)
for tag_data in tags_data]
activity_template.tags = tags
return activity_template return activity_template

View file

@ -1,56 +1,556 @@
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from event.models import Event, Place, ActivityTag, ActivityTemplate from event.models import Event, Place, ActivityTag, ActivityTemplate
from api.event.serializers import ActivityTemplateSerializer, EventSerializer from api.test.testcases import ModelAPITestCaseMixin
from api.testcases import EventBasedModelTestMixin, EventSpecificTestMixin,\ from api.test.utils import json_format
ModelTestMixin
User = get_user_model() User = get_user_model()
class EventTest(ModelTestMixin, APITestCase): class EventAPITests(ModelAPITestCaseMixin, APITestCase):
model = Event model = Event
base_name = 'event' url_label = 'event'
tested_fields = {'title': "I'm a test", }
# Création list_ordering = 'beginning_date'
data_creation = 'event2_data'
# Update/Delete @property
instance_name = 'event1' def user_auth_mapping(self):
serializer = EventSerializer 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 model = ActivityTemplate
base_name = 'event-activitytemplate' url_label = 'activitytemplate'
initial_count = 1
# Creation
data_creation = 'act_temp2_data'
# Update/Delete
instance_name = 'act_temp1'
field_tested = 'title'
serializer = ActivityTemplateSerializer
def test_create_extra(self): list_ordering = 'title'
self.assertEqual(self.model.objects.get(id=1).tags.count(), 1)
def pre_update_extra(self, data): def setUp(self):
data['tags'].append(self.tag2_data) 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 return data
def post_update_extra(self, instance): @property
self.assertEqual(instance.tags.count(), 2) def update_expected(self):
return {
**self.update_data,
'event': None,
}
class EventSpecficTagTest(EventSpecificTestMixin, APITestCase): class EventActivityTagAPITests(
model = ActivityTag BaseActivityTagAPITests,
root_base_name = 'activitytag' ModelAPITestCaseMixin,
event_base_name = 'event-activitytag' 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 model = Place
root_base_name = 'place' url_label = 'place'
event_base_name = 'event-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.contrib.auth import get_user_model
from django.db.models import Q 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.viewsets import ModelViewSet
from rest_framework.filters import OrderingFilter
from api.event.serializers import EventSerializer, PlaceSerializer,\ from event.models import Activity, ActivityTag, ActivityTemplate, Event, Place
ActivityTagSerializer, ActivityTemplateSerializer, ActivitySerializer
from event.models import Event, Place, ActivityTag, ActivityTemplate, Activity from .serializers import (
ActivitySerializer, ActivityTagSerializer, ActivityTemplateSerializer,
EventSerializer, PlaceSerializer,
)
User = get_user_model() User = get_user_model()
# classes utilitaires # 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 : ViewSet that returns :
* rootlevel objects if no Event is specified * rootlevel objects if no Event is specified
@ -21,81 +58,64 @@ class EventSpecificViewSet(ModelViewSet):
to the save method. Works fine with serializers.EventSpecificSerializer to the save method. Works fine with serializers.EventSpecificSerializer
Useful for models that extends EventSpecificMixin Useful for models that extends EventSpecificMixin
""" """
def get_queryset(self): def get_queryset(self):
""" """
Warning : You may want to override this method Warning : You may want to override this method
and not call with super and not call with super
""" """
event_pk = self.kwargs.get('event_pk')
queryset = super().get_queryset() queryset = super().get_queryset()
if event_pk: filters = Q(event=None)
return queryset.filter(Q(event=event_pk) | Q(event=None)) if self.event:
return queryset.filter(event=None) filters |= Q(event=self.event)
return queryset.filter(filters)
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)
# ViewSets # ViewSets
class EventViewSet(ModelViewSet): class EventViewSet(ModelViewSet):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions.
"""
queryset = Event.objects.all() queryset = Event.objects.all()
serializer_class = EventSerializer serializer_class = EventSerializer
filter_backends = (OrderingFilter,)
ordering_fields = ('title', 'creation_date', 'beginning_date',
'ending_date', )
ordering = ('beginning_date', )
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(created_by=self.request.user) serializer.save(created_by=self.request.user)
class PlaceViewSet(EventSpecificViewSet): class PlaceViewSet(EventSpecificModelViewSet):
queryset = Place.objects.all() queryset = Place.objects.all()
serializer_class = PlaceSerializer serializer_class = PlaceSerializer
filter_backends = (OrderingFilter,)
ordering_fields = ('name', )
ordering = ('name', )
class ActivityTagViewSet(EventSpecificViewSet):
class ActivityTagViewSet(EventSpecificModelViewSet):
queryset = ActivityTag.objects.all() queryset = ActivityTag.objects.all()
serializer_class = ActivityTagSerializer 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() queryset = ActivityTemplate.objects.all()
serializer_class = ActivityTemplateSerializer serializer_class = ActivityTemplateSerializer
def perform_create(self, serializer): filter_backends = (OrderingFilter,)
event_pk = self.kwargs.get('event_pk') ordering_fields = ('title', )
serializer.save(event_pk=event_pk) ordering = ('title', )
def perform_update(self, serializer):
event_pk = self.kwargs.get('event_pk')
serializer.save(event_pk=event_pk)
class ActivityViewSet(ModelViewSet): class ActivityViewSet(EventModelViewSet):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions.
"""
queryset = Activity.objects.all() queryset = Activity.objects.all()
serializer_class = ActivitySerializer serializer_class = ActivitySerializer
def perform_create(self, serializer): filter_backends = (OrderingFilter,)
event_pk = self.kwargs.get('event_pk') ordering_fields = ('title', )
serializer.save(event_pk=event_pk) ordering = ('title', )
def perform_update(self, serializer):
event_pk = self.kwargs.get('event_pk')
serializer.save(event_pk=event_pk)

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 django.conf.urls import url, include
from rest_framework_nested.routers import SimpleRouter, NestedSimpleRouter 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 = SimpleRouter()
router.register(r'event', EventViewSet, 'event') router.register(r'event', views.EventViewSet)
router.register(r'place', PlaceViewSet, 'place') router.register(r'place', views.PlaceViewSet)
router.register(r'activitytag', ActivityTagViewSet, 'activitytag') 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 = NestedSimpleRouter(router, r'event', lookup='event')
event_router.register(r'place', PlaceViewSet, base_name='event-place') event_router.register(r'place', views.PlaceViewSet)
event_router.register(r'tag', ActivityTagViewSet, base_name='event-activitytag') event_router.register(r'tag', views.ActivityTagViewSet)
event_router.register(r'activitytemplate', ActivityTemplateViewSet, event_router.register(r'template', views.ActivityTemplateViewSet)
base_name='event-activitytemplate')
# The API URLs are now determined automatically by the router. # API URLconf: routers + auth for browsable API.
# Additionally, we include the login URLs for the browsable API.
urlpatterns = [ urlpatterns = [
url(r'^', include(router.urls)), url(r'^', include(router.urls)),
url(r'^', include(event_router.urls)), url(r'^', include(event_router.urls)),
url(r'^api-auth/', include('rest_framework.urls', url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
namespace='rest_framework'))
] ]

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

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- 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 __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
@ -65,6 +65,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='equipment', model_name='equipment',
name='event', 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', 'rest_framework.permissions.AllowAny',
], ],
'PAGE_SIZE': 10, 'PAGE_SIZE': 10,
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
} }
ROOT_URLCONF = 'evenementiel.urls' ROOT_URLCONF = 'evenementiel.urls'

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- 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 __future__ import unicode_literals
from django.conf import settings from django.conf import settings
@ -22,12 +22,12 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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é")), ('title', models.CharField(blank=True, max_length=200, null=True, verbose_name="nom de l'activité")),
('is_public', models.NullBooleanField()), ('is_public', models.NullBooleanField(verbose_name='est public')),
('has_perm', models.NullBooleanField()), ('has_perm', models.NullBooleanField(verbose_name='inscription de permanents')),
('min_perm', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='nombre minimum 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')), ('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')), ('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')), ('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')), ('beginning', models.DateTimeField(verbose_name='heure de début')),
('end', models.DateTimeField(verbose_name='heure de fin')), ('end', models.DateTimeField(verbose_name='heure de fin')),
], ],
@ -41,8 +41,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('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")), ('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', 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')), ('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={ options={
'verbose_name': 'tag', 'verbose_name': 'tag',
@ -54,12 +54,12 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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é")), ('title', models.CharField(blank=True, max_length=200, null=True, verbose_name="nom de l'activité")),
('is_public', models.NullBooleanField()), ('is_public', models.NullBooleanField(verbose_name='est public')),
('has_perm', models.NullBooleanField()), ('has_perm', models.NullBooleanField(verbose_name='inscription de permanents')),
('min_perm', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='nombre minimum 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')), ('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')), ('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')), ('remarks', models.TextField(blank=True, help_text='Visible uniquement par les organisateurs.', null=True, verbose_name='remarques')),
], ],
options={ options={
'verbose_name': 'template activité', 'verbose_name': 'template activité',
@ -71,12 +71,12 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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")), ('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')), ('slug', models.SlugField(help_text="Seulement des lettres, des chiffres ou les caractères '_' ou '-'.", unique=True, verbose_name='identificateur')),
('creation_date', models.DateTimeField(auto_now_add=True, verbose_name='date de création')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='date de création')),
('description', models.TextField(verbose_name='description')), ('description', models.TextField(verbose_name='description')),
('beginning_date', models.DateTimeField(verbose_name='date de début')), ('beginning_date', models.DateTimeField(verbose_name='date de début')),
('ending_date', models.DateTimeField(verbose_name='date de fin')), ('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={ options={
'verbose_name': 'évènement', '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')), ('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')), ('name', models.CharField(max_length=200, verbose_name='nom du lieu')),
('description', models.TextField(blank=True)), ('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={ options={
'verbose_name': 'lieu', 'verbose_name': 'lieu',
@ -99,46 +99,46 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='activitytemplate', model_name='activitytemplate',
name='event', 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( migrations.AddField(
model_name='activitytemplate', model_name='activitytemplate',
name='place', name='places',
field=models.ManyToManyField(blank=True, to='event.Place'), field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'),
), ),
migrations.AddField( migrations.AddField(
model_name='activitytemplate', model_name='activitytemplate',
name='tags', name='tags',
field=models.ManyToManyField(blank=True, to='event.ActivityTag'), field=models.ManyToManyField(blank=True, to='event.ActivityTag', verbose_name='tags'),
), ),
migrations.AddField( migrations.AddField(
model_name='activitytag', model_name='activitytag',
name='event', 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( migrations.AddField(
model_name='activity', model_name='activity',
name='event', 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( migrations.AddField(
model_name='activity', model_name='activity',
name='parent', 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( migrations.AddField(
model_name='activity', model_name='activity',
name='place', name='places',
field=models.ManyToManyField(blank=True, to='event.Place'), field=models.ManyToManyField(blank=True, to='event.Place', verbose_name='lieux'),
), ),
migrations.AddField( migrations.AddField(
model_name='activity', model_name='activity',
name='staff', 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( migrations.AddField(
model_name='activity', model_name='activity',
name='tags', 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.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import FieldDoesNotExist, FieldError
from django.core.validators import RegexValidator
from django.core.exceptions import FieldError
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _
from communication.models import SubscriptionMixin from communication.models import SubscriptionMixin
from .validators import ColorValidator
User = get_user_model() User = get_user_model()
class Event(SubscriptionMixin, models.Model): class Event(SubscriptionMixin, models.Model):
title = models.CharField( title = models.CharField(
_("nom de l'évènement"), _("nom de l'évènement"),
max_length=200, max_length=200,
) )
slug = models.SlugField( slug = models.SlugField(
_('identificateur'), _("identificateur"),
unique=True, unique=True,
help_text=_("Seulement des lettres, des chiffres ou" help_text=_(
"les caractères '_' ou '-'."), "Seulement des lettres, des chiffres ou les caractères '_' ou '-'."
) ),
)
created_by = models.ForeignKey( created_by = models.ForeignKey(
User, User,
related_name="created_events", verbose_name=_("créé par"),
editable=False, on_delete=models.SET_NULL,
) related_name="created_events",
editable=False, null=True,
)
created_at = models.DateTimeField( created_at = models.DateTimeField(
_('date de création'), _("date de création"),
auto_now_add=True, auto_now_add=True,
) )
description = models.TextField(_('description')) description = models.TextField(_("description"))
beginning_date = models.DateTimeField(_('date de début')) beginning_date = models.DateTimeField(_("date de début"))
ending_date = models.DateTimeField(_('date de fin')) ending_date = models.DateTimeField(_("date de fin"))
class Meta: class Meta:
verbose_name = _("évènement") verbose_name = _("évènement")
@ -45,12 +50,14 @@ class EventSpecificMixin(models.Model):
or not (depending on whether the event field is null)""" or not (depending on whether the event field is null)"""
event = models.ForeignKey( event = models.ForeignKey(
'event.Event', Event,
verbose_name=_("évènement"), verbose_name=_("évènement"),
help_text=_("Si spécifié, l'instance du modèle " help_text=_(
"est spécifique à l'évènement en question"), "Si spécifié, l'instance du modèle est spécifique à l'évènement "
blank=True, "en question."
null=True ),
on_delete=models.CASCADE,
blank=True, null=True,
) )
class Meta: class Meta:
@ -59,9 +66,9 @@ class EventSpecificMixin(models.Model):
class Place(EventSpecificMixin, models.Model): class Place(EventSpecificMixin, models.Model):
name = models.CharField( name = models.CharField(
_("nom du lieu"), _("nom du lieu"),
max_length=200, max_length=200,
) )
description = models.TextField(blank=True) description = models.TextField(blank=True)
class Meta: class Meta:
@ -74,26 +81,22 @@ class Place(EventSpecificMixin, models.Model):
class ActivityTag(EventSpecificMixin, models.Model): class ActivityTag(EventSpecificMixin, models.Model):
name = models.CharField( name = models.CharField(
_("nom du tag"), _("nom du tag"),
max_length=200, max_length=200,
) )
is_public = models.BooleanField( is_public = models.BooleanField(
help_text=_("Sert à faire une distinction dans" _("est public"),
" l'affichage selon que cela soit" help_text=_(
" destiné au public ou à l'équipe" "Sert à faire une distinction dans l'affichage selon que le tag "
" organisatrice"), "soit destiné au public ou à l'organisation."
) ),
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."),
)
color = models.CharField( color = models.CharField(
_('couleur'), _('couleur'),
max_length=7, max_length=7,
validators=[color_regex], validators=[ColorValidator],
help_text=_("Rentrer une couleur en hexadécimal"), help_text=_("Rentrer une couleur en hexadécimal (#XXX ou #XXXXXX)."),
) )
class Meta: class Meta:
verbose_name = _("tag") verbose_name = _("tag")
@ -105,49 +108,53 @@ class ActivityTag(EventSpecificMixin, models.Model):
class AbstractActivityTemplate(SubscriptionMixin, models.Model): class AbstractActivityTemplate(SubscriptionMixin, models.Model):
title = models.CharField( title = models.CharField(
_("nom de l'activité"), _("nom de l'activité"),
max_length=200, max_length=200,
blank=True, blank=True, null=True,
null=True, )
)
# FIXME: voir comment on traite l'héritage de `event` # 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( is_public = models.NullBooleanField(
blank=True, _("est public"),
) blank=True,
)
has_perm = models.NullBooleanField( has_perm = models.NullBooleanField(
blank=True, _("inscription de permanents"),
) blank=True,
)
min_perm = models.PositiveSmallIntegerField( min_perm = models.PositiveSmallIntegerField(
_('nombre minimum de permanents'), _('nombre minimum de permanents'),
blank=True, blank=True, null=True,
null=True, )
)
max_perm = models.PositiveSmallIntegerField( max_perm = models.PositiveSmallIntegerField(
_('nombre maximum de permanents'), _('nombre maximum de permanents'),
blank=True, blank=True, null=True,
null=True, )
)
description = models.TextField( description = models.TextField(
_('description'), _('description'),
help_text=_("Public, Visible par tout le monde."), help_text=_("Visible par tout le monde si l'événément est public."),
blank=True, blank=True, null=True,
null=True, )
)
remarks = models.TextField( remarks = models.TextField(
_('remarques'), _('remarques'),
help_text=_("Visible uniquement par les organisateurs"), help_text=_("Visible uniquement par les organisateurs."),
blank=True, blank=True, null=True,
null=True, )
)
tags = models.ManyToManyField( tags = models.ManyToManyField(
ActivityTag, ActivityTag,
blank=True, verbose_name=_('tags'),
) blank=True,
place = models.ManyToManyField( )
Place, places = models.ManyToManyField(
blank=True, Place,
) verbose_name=_('lieux'),
blank=True,
)
class Meta: class Meta:
abstract = True abstract = True
@ -164,40 +171,42 @@ class ActivityTemplate(AbstractActivityTemplate):
class Activity(AbstractActivityTemplate): class Activity(AbstractActivityTemplate):
parent = models.ForeignKey( parent = models.ForeignKey(
ActivityTemplate, ActivityTemplate,
related_name="children", verbose_name=_("template"),
blank=True, on_delete=models.PROTECT,
null=True, related_name="children",
) blank=True, null=True,
)
staff = models.ManyToManyField( staff = models.ManyToManyField(
User, User,
related_name="in_perm_activities", verbose_name=_("permanents"),
blank=True, related_name="in_perm_activities",
) blank=True,
)
beginning = models.DateTimeField(_("heure de début")) beginning = models.DateTimeField(_("heure de début"))
end = models.DateTimeField(_("heure de fin")) end = models.DateTimeField(_("heure de fin"))
def get_herited(self, attrname): def get_herited(self, attrname):
inherited_fields = [f.name for f in try:
ActivityTemplate._meta.get_fields()] tpl_field = ActivityTemplate._meta.get_field(attrname)
m2m_fields = [f.name for f in ActivityTemplate._meta.get_fields() except FieldDoesNotExist:
if f.many_to_many]
attr = getattr(self, attrname)
if attrname not in inherited_fields:
raise FieldError( raise FieldError(
_("%(attrname)s n'est pas un champ héritable"), "%(attrname)s field can't be herited.",
params={'attrname': attrname}, params={'attrname': attrname},
) )
elif attrname in m2m_fields:
if attr.exists(): value = getattr(self, attrname)
return attr
if tpl_field.many_to_many:
if value.exists():
return value
else: else:
return getattr(self.parent, attrname) return getattr(self.parent, attrname)
elif attr is None: elif value is None:
return getattr(self.parent, attrname) return getattr(self.parent, attrname)
else: else:
return attr return value
class Meta: class Meta:
verbose_name = _("activité") verbose_name = _("activité")

View file

@ -97,14 +97,14 @@ class ActivityInheritanceTest(TestCase):
self.assertEqual(self.real_act.get_herited('max_perm'), 1) self.assertEqual(self.real_act.get_herited('max_perm'), 1)
def test_inherits_place(self): def test_inherits_place(self):
self.template_act.place.add(self.loge) self.template_act.places.add(self.loge)
self.assertEqual( self.assertEqual(
self.real_act.get_herited('place').get(), self.real_act.get_herited('places').get(),
self.loge self.loge
) )
self.real_act.place.add(self.aqua) self.real_act.places.add(self.aqua)
self.assertEqual( self.assertEqual(
self.real_act.get_herited('place').get(), self.real_act.get_herited('places').get(),
self.aqua 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