Merge branch 'Qwann/Serializers' into 'master'
Event Seralizers (part 1) See merge request !17
This commit is contained in:
commit
677c7f3367
24 changed files with 1750 additions and 179 deletions
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
7
api/equipment/serializers.py
Normal file
7
api/equipment/serializers.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from equipment.models import Equipment
|
||||||
|
from api.event.serializers import EventSpecificSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# TODO : le faire
|
||||||
|
class EquipmentSerializer(EventSpecificSerializer):
|
||||||
|
pass
|
0
api/event/__init__.py
Normal file
0
api/event/__init__.py
Normal file
33
api/event/fields.py
Normal file
33
api/event/fields.py
Normal 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
|
92
api/event/serializers.py
Normal file
92
api/event/serializers.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from event.models import Event, ActivityTag, Place, ActivityTemplate
|
||||||
|
|
||||||
|
from .fields import EventHyperlinkedIdentityField
|
||||||
|
|
||||||
|
|
||||||
|
# Event Serializer
|
||||||
|
class EventSerializer(serializers.ModelSerializer):
|
||||||
|
# TODO: Change this to a nested serializer ~(url, full_name) of User
|
||||||
|
created_by = serializers.ReadOnlyField(source='created_by.get_full_name')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = (
|
||||||
|
'url', 'id', 'title', 'slug', 'created_by', 'created_at',
|
||||||
|
'description', 'beginning_date', 'ending_date',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Serializers
|
||||||
|
# TODO rajouter des permissions
|
||||||
|
class PlaceSerializer(serializers.ModelSerializer):
|
||||||
|
serializer_url_field = EventHyperlinkedIdentityField
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Place
|
||||||
|
fields = ('url', 'id', 'name', 'description', 'event')
|
||||||
|
|
||||||
|
|
||||||
|
# TODO rajouter des permissions
|
||||||
|
class ActivityTagSerializer(serializers.ModelSerializer):
|
||||||
|
serializer_url_field = EventHyperlinkedIdentityField
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ActivityTag
|
||||||
|
fields = ('url', 'id', 'name', 'is_public', 'color', 'event')
|
||||||
|
|
||||||
|
|
||||||
|
# TODO rajouter des permissions
|
||||||
|
class ActivityTemplateSerializer(serializers.ModelSerializer):
|
||||||
|
tags = ActivityTagSerializer(many=True)
|
||||||
|
|
||||||
|
serializer_url_field = EventHyperlinkedIdentityField
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ActivityTemplate
|
||||||
|
fields = (
|
||||||
|
'url', 'id', 'title', 'event', 'is_public', 'has_perm', 'min_perm',
|
||||||
|
'max_perm', 'description', 'remarks', 'tags',
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_tags(self, instance, tags_data):
|
||||||
|
# TODO: en fonction de si backbone envoie un `id` ou non lorsque le tag
|
||||||
|
# n'existe pas encore il faudra faire un premier passage sur `tags` i
|
||||||
|
# pour s'assurer que le get ne foire pas le get si, par exemple, le tag
|
||||||
|
# été modifié entre temps dans la base de donnée (mais pas sur la
|
||||||
|
# classe backbone
|
||||||
|
tags = []
|
||||||
|
for tag_data in tags_data:
|
||||||
|
tag, _ = ActivityTag.objects.get_or_create(**tag_data, defaults={
|
||||||
|
'event': instance.event,
|
||||||
|
})
|
||||||
|
tags.append(tag)
|
||||||
|
instance.tags.add(*tags)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""
|
||||||
|
@tags comportement attendu : si l'id existe déjà on ne change pas
|
||||||
|
les autres champs et si l'id n'existe pas on le créé
|
||||||
|
"""
|
||||||
|
tags_data = validated_data.pop('tags')
|
||||||
|
activity_template = super().create(validated_data)
|
||||||
|
self.process_tags(activity_template, tags_data)
|
||||||
|
return activity_template
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""
|
||||||
|
@tags comportement attendu : si l'id existe déjà on ne change pas
|
||||||
|
les autres champs et si l'id n'existe pas on le créé
|
||||||
|
"""
|
||||||
|
tags_data = validated_data.pop('tags')
|
||||||
|
activity_template = super().update(instance, validated_data)
|
||||||
|
self.process_tags(activity_template, tags_data)
|
||||||
|
return activity_template
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitySerializer(serializers.ModelSerializer):
|
||||||
|
pass
|
556
api/event/tests.py
Normal file
556
api/event/tests.py
Normal file
|
@ -0,0 +1,556 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from event.models import Event, Place, ActivityTag, ActivityTemplate
|
||||||
|
|
||||||
|
from api.test.testcases import ModelAPITestCaseMixin
|
||||||
|
from api.test.utils import json_format
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class EventAPITests(ModelAPITestCaseMixin, APITestCase):
|
||||||
|
model = Event
|
||||||
|
url_label = 'event'
|
||||||
|
|
||||||
|
list_ordering = 'beginning_date'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_auth_mapping(self):
|
||||||
|
return {
|
||||||
|
'create': self.user,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_expected_data(self, instance):
|
||||||
|
return json_format({
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/event/{pk}/'
|
||||||
|
.format(pk=instance.pk)
|
||||||
|
),
|
||||||
|
'id': instance.id,
|
||||||
|
'title': instance.title,
|
||||||
|
'slug': instance.slug,
|
||||||
|
'description': instance.description,
|
||||||
|
'beginning_date': instance.beginning_date,
|
||||||
|
'ending_date': instance.ending_date,
|
||||||
|
'created_by': instance.created_by.get_full_name(),
|
||||||
|
'created_at': self.now,
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instances_data(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'title': "A first event",
|
||||||
|
'slug': 'first-event',
|
||||||
|
'description': "It's the first event.",
|
||||||
|
'beginning_date': self.now + timedelta(days=100),
|
||||||
|
'ending_date': self.now + timedelta(days=120),
|
||||||
|
'created_by': self.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': "Another event",
|
||||||
|
'slug': 'another-event',
|
||||||
|
'description': "It's another event.",
|
||||||
|
'beginning_date': self.now + timedelta(days=50),
|
||||||
|
'ending_date': self.now + timedelta(days=60),
|
||||||
|
'created_by': self.user,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_data(self):
|
||||||
|
return {
|
||||||
|
'title': "An Event",
|
||||||
|
'slug': 'event',
|
||||||
|
'description': "I am an event.",
|
||||||
|
'beginning_date': '2017-08-10 05:30:00',
|
||||||
|
'ending_date': '2017-10-21 17:16:20',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_expected(self):
|
||||||
|
return {
|
||||||
|
**self.create_data,
|
||||||
|
'beginning_date': timezone.make_aware(
|
||||||
|
datetime(2017, 8, 10, 5, 30, 0)),
|
||||||
|
'ending_date': timezone.make_aware(
|
||||||
|
datetime(2017, 10, 21, 17, 16, 20)),
|
||||||
|
'id': 1,
|
||||||
|
'created_by': self.user,
|
||||||
|
'created_at': self.now,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_data(self):
|
||||||
|
return {
|
||||||
|
'title': "An updated event.",
|
||||||
|
'slug': 'first-event',
|
||||||
|
'description': "It's the first updated event.",
|
||||||
|
'beginning_date': '2017-08-10 05:30:00',
|
||||||
|
'ending_date': '2017-10-21 17:16:20',
|
||||||
|
'created_by': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_expected(self):
|
||||||
|
return {
|
||||||
|
**self.update_data,
|
||||||
|
'beginning_date': timezone.make_aware(
|
||||||
|
datetime(2017, 8, 10, 5, 30, 0)),
|
||||||
|
'ending_date': timezone.make_aware(
|
||||||
|
datetime(2017, 10, 21, 17, 16, 20)),
|
||||||
|
'id': 1,
|
||||||
|
'created_by': self.user,
|
||||||
|
'created_at': self.now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityTemplateAPITests(ModelAPITestCaseMixin, APITestCase):
|
||||||
|
model = ActivityTemplate
|
||||||
|
url_label = 'activitytemplate'
|
||||||
|
|
||||||
|
list_ordering = 'title'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.event = Event.objects.create(**{
|
||||||
|
'title': "event1",
|
||||||
|
'slug': "slug1",
|
||||||
|
'beginning_date': self.now + timedelta(days=30),
|
||||||
|
'description': "C'est trop cool !",
|
||||||
|
'ending_date': self.now + timedelta(days=31),
|
||||||
|
'created_by': self.user,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.activity_tag = ActivityTag.objects.create(**{
|
||||||
|
'name': "tag2",
|
||||||
|
'is_public': False,
|
||||||
|
'color': "#222",
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_url_model(self, *args, **kwargs):
|
||||||
|
kwargs['event_pk'] = 1
|
||||||
|
return super().get_url_model(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_url_object(self, *args, **kwargs):
|
||||||
|
kwargs['event_pk'] = 1
|
||||||
|
return super().get_url_object(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_expected_data(self, instance):
|
||||||
|
return json_format({
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/event/{event_pk}/template/{pk}/'
|
||||||
|
.format(event_pk=instance.event.pk, pk=instance.pk)
|
||||||
|
),
|
||||||
|
'id': instance.id,
|
||||||
|
'title': instance.title,
|
||||||
|
'is_public': instance.is_public,
|
||||||
|
'remarks': instance.remarks,
|
||||||
|
'event': instance.event.pk,
|
||||||
|
'tags': [
|
||||||
|
{
|
||||||
|
**(tag.event and {
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/event/{event_pk}/'
|
||||||
|
'tag/{tag_pk}/'
|
||||||
|
.format(event_pk=tag.event.pk, tag_pk=tag.pk)
|
||||||
|
),
|
||||||
|
'event': tag.event.pk,
|
||||||
|
} or {
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/tag/{tag_pk}/'
|
||||||
|
.format(tag_pk=tag.pk)
|
||||||
|
),
|
||||||
|
'event': None,
|
||||||
|
}),
|
||||||
|
'id': tag.id,
|
||||||
|
'name': tag.name,
|
||||||
|
'is_public': tag.is_public,
|
||||||
|
'color': tag.color,
|
||||||
|
}
|
||||||
|
for tag in instance.tags.all()
|
||||||
|
],
|
||||||
|
'has_perm': instance.has_perm,
|
||||||
|
'min_perm': instance.min_perm,
|
||||||
|
'max_perm': instance.max_perm,
|
||||||
|
'description': instance.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instances_data(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'title': "act temp1",
|
||||||
|
'is_public': True,
|
||||||
|
'remarks': "test remark",
|
||||||
|
'event': self.event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': "act temp2",
|
||||||
|
'is_public': False,
|
||||||
|
'remarks': "test remark",
|
||||||
|
'event': self.event,
|
||||||
|
'tags': [self.activity_tag]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_data(self):
|
||||||
|
return {
|
||||||
|
'title': "act temp2",
|
||||||
|
'is_public': False,
|
||||||
|
'remarks': "test remark",
|
||||||
|
'tags': [
|
||||||
|
{
|
||||||
|
'name': "tag2",
|
||||||
|
'is_public': False,
|
||||||
|
'color': '#222',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_expected(self):
|
||||||
|
return {
|
||||||
|
**self.create_data,
|
||||||
|
'tags': [ActivityTag.objects.get(name='tag2')],
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_data(self):
|
||||||
|
return {
|
||||||
|
'title': "act temp3",
|
||||||
|
'is_public': False,
|
||||||
|
'remarks': "another test remark",
|
||||||
|
'tags': [
|
||||||
|
{
|
||||||
|
'name': "tag2",
|
||||||
|
'is_public': False,
|
||||||
|
'color': '#222',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': "body",
|
||||||
|
'is_public': True,
|
||||||
|
'color': '#555',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_expected(self):
|
||||||
|
tag_root = ActivityTag.objects.get(name='tag2')
|
||||||
|
self.assertIsNone(tag_root.event)
|
||||||
|
|
||||||
|
tag_bound = ActivityTag.objects.get(name='body')
|
||||||
|
self.assertEqual(tag_bound.event, self.event)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'title': "act temp3",
|
||||||
|
'is_public': False,
|
||||||
|
'remarks': "another test remark",
|
||||||
|
'tags': [tag_root, tag_bound]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BaseActivityTagAPITests:
|
||||||
|
model = ActivityTag
|
||||||
|
url_label = 'activitytag'
|
||||||
|
|
||||||
|
list_ordering = ('is_public', 'name')
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.event = Event.objects.create(**{
|
||||||
|
'title': "event1",
|
||||||
|
'slug': "slug1",
|
||||||
|
'beginning_date': self.now + timedelta(days=30),
|
||||||
|
'description': "C'est trop cool !",
|
||||||
|
'ending_date': self.now + timedelta(days=31),
|
||||||
|
'created_by': self.user,
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_expected_data(self, instance):
|
||||||
|
return {
|
||||||
|
**(instance.event and {
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/event/{event_pk}/tag/{pk}/'
|
||||||
|
.format(event_pk=instance.event.pk, pk=instance.pk)
|
||||||
|
),
|
||||||
|
'event': instance.event.pk,
|
||||||
|
} or {
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/tag/{pk}/'
|
||||||
|
.format(pk=instance.pk)
|
||||||
|
),
|
||||||
|
'event': None,
|
||||||
|
}),
|
||||||
|
'id': instance.id,
|
||||||
|
'name': instance.name,
|
||||||
|
'is_public': instance.is_public,
|
||||||
|
'color': instance.color,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instances_data(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'name': 'a tag',
|
||||||
|
'is_public': False,
|
||||||
|
'color': '#222',
|
||||||
|
'event': None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'another tag',
|
||||||
|
'is_public': True,
|
||||||
|
'color': '#555',
|
||||||
|
'event': self.event,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_data(self):
|
||||||
|
return {
|
||||||
|
'name': 'plop tag',
|
||||||
|
'is_public': True,
|
||||||
|
'color': '#888999',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_data(self):
|
||||||
|
return {
|
||||||
|
'name': 'this is the tag',
|
||||||
|
'is_public': True,
|
||||||
|
'color': '#333',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RootActivityTagAPITests(
|
||||||
|
BaseActivityTagAPITests,
|
||||||
|
ModelAPITestCaseMixin,
|
||||||
|
APITestCase
|
||||||
|
):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def list_expected(self):
|
||||||
|
return [ActivityTag.objects.get(name='a tag')]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_expected(self):
|
||||||
|
return {
|
||||||
|
**self.create_data,
|
||||||
|
'event': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instance_data(self):
|
||||||
|
data = self.instances_data[0]
|
||||||
|
self.assertIsNone(
|
||||||
|
data['event'],
|
||||||
|
msg="This test should use a tag unbound to any event.",
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_expected(self):
|
||||||
|
return {
|
||||||
|
**self.update_data,
|
||||||
|
'event': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EventActivityTagAPITests(
|
||||||
|
BaseActivityTagAPITests,
|
||||||
|
ModelAPITestCaseMixin,
|
||||||
|
APITestCase
|
||||||
|
):
|
||||||
|
|
||||||
|
def get_url_model(self, *args, **kwargs):
|
||||||
|
kwargs['event_pk'] = 1
|
||||||
|
return super().get_url_model(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_url_object(self, *args, **kwargs):
|
||||||
|
kwargs['event_pk'] = 1
|
||||||
|
return super().get_url_object(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def list_expected(self):
|
||||||
|
return [
|
||||||
|
ActivityTag.objects.get(name='a tag'),
|
||||||
|
ActivityTag.objects.get(name='another tag'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_expected(self):
|
||||||
|
return {
|
||||||
|
**self.create_data,
|
||||||
|
'event': self.event,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instance_data(self):
|
||||||
|
data = self.instances_data[1]
|
||||||
|
self.assertIsNotNone(
|
||||||
|
data['event'],
|
||||||
|
msg="This test should use an event-bound tag.",
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_expected(self):
|
||||||
|
return {
|
||||||
|
**self.update_data,
|
||||||
|
'event': self.event,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BasePlaceAPITests:
|
||||||
|
model = Place
|
||||||
|
url_label = 'place'
|
||||||
|
|
||||||
|
list_ordering = 'name'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.event = Event.objects.create(**{
|
||||||
|
'title': "event1",
|
||||||
|
'slug': "slug1",
|
||||||
|
'beginning_date': self.now + timedelta(days=30),
|
||||||
|
'description': "C'est trop cool !",
|
||||||
|
'ending_date': self.now + timedelta(days=31),
|
||||||
|
'created_by': self.user,
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_expected_data(self, instance):
|
||||||
|
return {
|
||||||
|
**(instance.event and {
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/event/{event_pk}/place/{pk}/'
|
||||||
|
.format(event_pk=instance.event.pk, pk=instance.pk)
|
||||||
|
),
|
||||||
|
'event': instance.event.pk,
|
||||||
|
} or {
|
||||||
|
'url': (
|
||||||
|
'http://testserver/api/place/{pk}/'
|
||||||
|
.format(pk=instance.pk)
|
||||||
|
),
|
||||||
|
'event': None,
|
||||||
|
}),
|
||||||
|
'id': instance.id,
|
||||||
|
'name': instance.name,
|
||||||
|
'description': instance.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instances_data(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'name': 'a place',
|
||||||
|
'event': None,
|
||||||
|
'description': 'a description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'another place',
|
||||||
|
'event': self.event,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_data(self):
|
||||||
|
return {
|
||||||
|
'name': 'plop place',
|
||||||
|
'description': 'the couro is a chill place',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_data(self):
|
||||||
|
return {
|
||||||
|
'name': 'this is the place',
|
||||||
|
'description': 'to be',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RootPlaceAPITests(
|
||||||
|
BasePlaceAPITests,
|
||||||
|
ModelAPITestCaseMixin,
|
||||||
|
APITestCase
|
||||||
|
):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def list_expected(self):
|
||||||
|
return [Place.objects.get(name='a place')]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_expected(self):
|
||||||
|
return {
|
||||||
|
**self.create_data,
|
||||||
|
'event': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instance_data(self):
|
||||||
|
data = self.instances_data[0]
|
||||||
|
self.assertIsNone(
|
||||||
|
data['event'],
|
||||||
|
msg="This test should use a place unbound to any event.",
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_expected(self):
|
||||||
|
return {
|
||||||
|
**self.update_data,
|
||||||
|
'event': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EventPlaceTagAPITests(
|
||||||
|
BasePlaceAPITests,
|
||||||
|
ModelAPITestCaseMixin,
|
||||||
|
APITestCase
|
||||||
|
):
|
||||||
|
|
||||||
|
def get_url_model(self, *args, **kwargs):
|
||||||
|
kwargs['event_pk'] = 1
|
||||||
|
return super().get_url_model(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_url_object(self, *args, **kwargs):
|
||||||
|
kwargs['event_pk'] = 1
|
||||||
|
return super().get_url_object(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def list_expected(self):
|
||||||
|
return [
|
||||||
|
Place.objects.get(name='a place'),
|
||||||
|
Place.objects.get(name='another place'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def create_expected(self):
|
||||||
|
return {
|
||||||
|
**self.create_data,
|
||||||
|
'event': self.event,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instance_data(self):
|
||||||
|
data = self.instances_data[1]
|
||||||
|
self.assertIsNotNone(
|
||||||
|
data['event'],
|
||||||
|
msg="This test should use an event-bound place.",
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_expected(self):
|
||||||
|
return {
|
||||||
|
**self.update_data,
|
||||||
|
'event': self.event,
|
||||||
|
}
|
121
api/event/views.py
Normal file
121
api/event/views.py
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.filters import OrderingFilter
|
||||||
|
|
||||||
|
from event.models import Activity, ActivityTag, ActivityTemplate, Event, Place
|
||||||
|
|
||||||
|
from .serializers import (
|
||||||
|
ActivitySerializer, ActivityTagSerializer, ActivityTemplateSerializer,
|
||||||
|
EventSerializer, PlaceSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# classes utilitaires
|
||||||
|
|
||||||
|
class EventUrlViewSetMixin:
|
||||||
|
"""
|
||||||
|
ViewSet mixin to handle the evenk_pk from url.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def event(self):
|
||||||
|
event_pk = self.kwargs.get('event_pk')
|
||||||
|
if event_pk:
|
||||||
|
return get_object_or_404(Event, pk=event_pk)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class EventModelViewSetMixin:
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(event=self.event)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
serializer.save(event=self.event)
|
||||||
|
|
||||||
|
|
||||||
|
class EventModelViewSet(
|
||||||
|
EventModelViewSetMixin,
|
||||||
|
EventUrlViewSetMixin,
|
||||||
|
ModelViewSet,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EventSpecificModelViewSet(EventModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet that returns :
|
||||||
|
* rootlevel objects if no Event is specified
|
||||||
|
* OR objects related to the specified event
|
||||||
|
AND root level objects
|
||||||
|
if an event is specified it passes the event_pk
|
||||||
|
to the save method. Works fine with serializers.EventSpecificSerializer
|
||||||
|
Useful for models that extends EventSpecificMixin
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Warning : You may want to override this method
|
||||||
|
and not call with super
|
||||||
|
"""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
filters = Q(event=None)
|
||||||
|
if self.event:
|
||||||
|
filters |= Q(event=self.event)
|
||||||
|
return queryset.filter(filters)
|
||||||
|
|
||||||
|
|
||||||
|
# ViewSets
|
||||||
|
class EventViewSet(ModelViewSet):
|
||||||
|
queryset = Event.objects.all()
|
||||||
|
serializer_class = EventSerializer
|
||||||
|
|
||||||
|
filter_backends = (OrderingFilter,)
|
||||||
|
ordering_fields = ('title', 'creation_date', 'beginning_date',
|
||||||
|
'ending_date', )
|
||||||
|
ordering = ('beginning_date', )
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(created_by=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceViewSet(EventSpecificModelViewSet):
|
||||||
|
queryset = Place.objects.all()
|
||||||
|
serializer_class = PlaceSerializer
|
||||||
|
|
||||||
|
filter_backends = (OrderingFilter,)
|
||||||
|
ordering_fields = ('name', )
|
||||||
|
ordering = ('name', )
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityTagViewSet(EventSpecificModelViewSet):
|
||||||
|
queryset = ActivityTag.objects.all()
|
||||||
|
serializer_class = ActivityTagSerializer
|
||||||
|
|
||||||
|
filter_backends = (OrderingFilter,)
|
||||||
|
ordering_fields = ('is_public', 'name', )
|
||||||
|
ordering = ('is_public', 'name', )
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityTemplateViewSet(EventModelViewSet):
|
||||||
|
queryset = ActivityTemplate.objects.all()
|
||||||
|
serializer_class = ActivityTemplateSerializer
|
||||||
|
|
||||||
|
filter_backends = (OrderingFilter,)
|
||||||
|
ordering_fields = ('title', )
|
||||||
|
ordering = ('title', )
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityViewSet(EventModelViewSet):
|
||||||
|
queryset = Activity.objects.all()
|
||||||
|
serializer_class = ActivitySerializer
|
||||||
|
|
||||||
|
filter_backends = (OrderingFilter,)
|
||||||
|
ordering_fields = ('title', )
|
||||||
|
ordering = ('title', )
|
694
api/test/testcases.py
Normal file
694
api/test/testcases.py
Normal 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
19
api/test/utils.py
Normal 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)
|
26
api/urls.py
Normal file
26
api/urls.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
|
from rest_framework_nested.routers import SimpleRouter, NestedSimpleRouter
|
||||||
|
|
||||||
|
from api.event import views
|
||||||
|
|
||||||
|
|
||||||
|
router = SimpleRouter()
|
||||||
|
router.register(r'event', views.EventViewSet)
|
||||||
|
router.register(r'place', views.PlaceViewSet)
|
||||||
|
router.register(r'tag', views.ActivityTagViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
# Views behind /event/<event_pk>/...
|
||||||
|
event_router = NestedSimpleRouter(router, r'event', lookup='event')
|
||||||
|
event_router.register(r'place', views.PlaceViewSet)
|
||||||
|
event_router.register(r'tag', views.ActivityTagViewSet)
|
||||||
|
event_router.register(r'template', views.ActivityTemplateViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
# API URLconf: routers + auth for browsable API.
|
||||||
|
urlpatterns = [
|
||||||
|
url(r'^', include(router.urls)),
|
||||||
|
url(r'^', include(event_router.urls)),
|
||||||
|
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||||
|
]
|
0
api/users/__init__.py
Normal file
0
api/users/__init__.py
Normal file
38
api/users/serializers.py
Normal file
38
api/users/serializers.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserMinimalSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
"""
|
||||||
|
Déstiné à tout le monde (de connecté)
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('url', 'id', 'first_name', 'last_name',)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdminSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
"""
|
||||||
|
Déstiné à l'utilisat-rice-eur et aux administrat-rice-eur-s
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('url', 'id', 'username', 'first_name', 'last_name',
|
||||||
|
'email', 'phone', 'last_login', 'date_joined',)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
"""
|
||||||
|
Déstiné aux utilisat-rice-eur-s dont l'utilisait-rice-eur-s en question
|
||||||
|
a laissé le droit d'y accéder, par exemple les participant-e-s
|
||||||
|
au même `event` que l'utilisat-rice-eur en question
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('url', 'id', 'first_name', 'last_name',
|
||||||
|
'email', 'phone',)
|
0
api/users/views.py
Normal file
0
api/users/views.py
Normal 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'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -56,8 +56,10 @@ INSTALLED_APPS = [
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'channels',
|
'channels',
|
||||||
|
'rest_framework',
|
||||||
'bootstrapform',
|
'bootstrapform',
|
||||||
'widget_tweaks',
|
'widget_tweaks',
|
||||||
|
'api',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE_CLASSES = [
|
MIDDLEWARE_CLASSES = [
|
||||||
|
@ -71,6 +73,14 @@ MIDDLEWARE_CLASSES = [
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.AllowAny',
|
||||||
|
],
|
||||||
|
'PAGE_SIZE': 10,
|
||||||
|
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
|
||||||
|
}
|
||||||
|
|
||||||
ROOT_URLCONF = 'evenementiel.urls'
|
ROOT_URLCONF = 'evenementiel.urls'
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
|
@ -9,6 +9,7 @@ urlpatterns = [
|
||||||
url(r'^admin/', admin.site.urls),
|
url(r'^admin/', admin.site.urls),
|
||||||
url(r'^event/', include('event.urls')),
|
url(r'^event/', include('event.urls')),
|
||||||
url(r'^user/', include('users.urls')),
|
url(r'^user/', include('users.urls')),
|
||||||
|
url(r'^api/', include('api.urls')),
|
||||||
url(r'^', include('shared.urls')),
|
url(r'^', include('shared.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
209
event/models.py
209
event/models.py
|
@ -1,29 +1,34 @@
|
||||||
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,
|
||||||
|
@ -51,12 +56,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:
|
||||||
|
@ -65,9 +72,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:
|
||||||
|
@ -80,26 +87,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")
|
||||||
|
@ -111,49 +114,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
|
||||||
|
@ -170,44 +177,46 @@ 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é")
|
||||||
verbose_name_plural = _("activités")
|
verbose_name_plural = _("activités")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.get_herited('title')
|
return self.get_herited('title')
|
|
@ -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
10
event/validators.py
Normal 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."
|
||||||
|
),
|
||||||
|
)
|
|
@ -5,6 +5,8 @@ Pillow
|
||||||
channels
|
channels
|
||||||
django-bootstrap-form==3.2.1
|
django-bootstrap-form==3.2.1
|
||||||
django-widget-tweaks
|
django-widget-tweaks
|
||||||
|
djangorestframework==3.6.3
|
||||||
|
drf-nested-routers==0.90.0
|
||||||
django-notifications
|
django-notifications
|
||||||
django-contrib-comments
|
django-contrib-comments
|
||||||
|
|
||||||
|
|
0
users/migrations/__init__.py
Normal file
0
users/migrations/__init__.py
Normal file
Loading…
Reference in a new issue