Skip to content
This repository was archived by the owner on Jun 1, 2022. It is now read-only.

Commit c83e3a3

Browse files
committed
Merge pull request #253 from editorsnotes/rest-framework-3
Upgrade to Django REST framework 3
2 parents 529bc7c + 6029cf7 commit c83e3a3

19 files changed

+452
-269
lines changed

editorsnotes/api/filters.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def filter_queryset(self, request, queryset, view):
4141
query['query']['filtered']['filter'] = { 'and': filters }
4242

4343
en_index = get_index('main')
44-
return en_index.search_model(view.model, query)
44+
return en_index.search_model(view.queryset.model, query)
4545

4646
class ElasticSearchAutocompleteFilterBackend(BaseFilterBackend):
4747
def filter_queryset(self, request, queryset, view):
@@ -109,8 +109,8 @@ def filter_queryset(self, request, queryset, view):
109109
'post_tags': ['</strong>']
110110
}
111111

112-
if hasattr(view, 'model') and view.model is not None:
113-
return en_index.search_model(view.model, query)
112+
if hasattr(view, 'queryset') and view.queryset is not None:
113+
return en_index.search_model(view.queryset.model, query)
114114
else:
115115
# Should boost notes most of all
116116
return en_index.search(query)

editorsnotes/api/serializers/activity.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111

1212
# TODO: make these fields nested, maybe
1313
class ActivitySerializer(serializers.ModelSerializer):
14-
user = serializers.Field(source='user.username')
15-
project = serializers.Field(source='project.slug')
16-
type = serializers.Field(source='content_type.model')
14+
user = serializers.ReadOnlyField(source='user.username')
15+
project = serializers.ReadOnlyField(source='project.slug')
16+
type = serializers.ReadOnlyField(source='content_type.model')
1717
url = serializers.SerializerMethodField('get_object_url')
18-
title = serializers.Field(source='display_title')
18+
title = serializers.ReadOnlyField(source='display_title')
1919
action = serializers.SerializerMethodField('get_action_repr')
2020
class Meta:
2121
model = LogActivity

editorsnotes/api/serializers/base.py

Lines changed: 104 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
from django.core.urlresolvers import NoReverseMatch
2-
from rest_framework.relations import HyperlinkedRelatedField, RelatedField
1+
from django.core.urlresolvers import resolve, NoReverseMatch, Resolver404
2+
from rest_framework.relations import (HyperlinkedRelatedField, RelatedField,
3+
get_attribute)
34
from rest_framework.reverse import reverse
4-
from rest_framework.serializers import Field
5+
from rest_framework.serializers import ReadOnlyField, ModelSerializer, SerializerMethodField
56

6-
from editorsnotes.main.models import Topic, TopicAssignment
7+
from editorsnotes.main.models import Topic, TopicAssignment, Project
78

89
def nested_getattr(obj, attr_string):
910
for attr in attr_string.split('.'):
1011
obj = getattr(obj, attr)
1112
return obj
1213

13-
class URLField(Field):
14+
class CurrentProjectDefault:
15+
def set_context(self, serializer_field):
16+
self.project = serializer_field.context['request'].project
17+
18+
def __call__(self):
19+
return self.project
20+
21+
def __repr__(self):
22+
return u'%s()' % self.__class__.__name__
23+
24+
class URLField(ReadOnlyField):
1425
"""
1526
An identity URL field.
1627
@@ -26,113 +37,119 @@ def _get_default_view_name(self, obj):
2637
return 'api:api-{}-detail'.format(obj.__class__._meta.verbose_name_plural[:])
2738
def _get_lookup_args(self, obj):
2839
return tuple(nested_getattr(obj, attr) for attr in self.lookup_args)
29-
def field_to_native(self, obj, field):
30-
view = self.view_name or self._get_default_view_name(obj)
31-
args = self._get_lookup_args(obj)
40+
def get_attribute(self, obj):
41+
return obj
42+
def to_representation(self, value):
43+
view = self.view_name or self._get_default_view_name(value)
44+
args = self._get_lookup_args(value)
3245
return reverse(view, args=args, request=self.context['request'])
3346

34-
class ProjectSlugField(Field):
35-
read_only = True
36-
def field_to_native(self, obj, field_name):
37-
project = obj.get_affiliation()
38-
url = reverse('api:api-project-detail', args=(project.slug,),
47+
class ProjectSlugField(ReadOnlyField):
48+
def __init__(self, *args, **kwargs):
49+
self.queryset = Project.objects.all()
50+
super(ProjectSlugField, self).__init__(*args, **kwargs)
51+
def get_attribute(self, obj):
52+
return obj.get_affiliation()
53+
def to_representation(self, value):
54+
url = reverse('api:api-project-detail', args=(value.slug,),
3955
request=self.context['request'])
40-
return { 'name': project.name, 'url': url }
56+
return { 'name': value.name, 'url': url }
4157

4258
class HyperlinkedProjectItemField(HyperlinkedRelatedField):
43-
def to_native(self, obj):
59+
def get_attribute(self, obj):
60+
return get_attribute(obj, self.source_attrs)
61+
def to_representation(self, value):
4462
"""
4563
Return URL from item requiring project slug kwarg
4664
"""
4765
try:
4866
return reverse(
49-
self.view_name, args=[obj.project.slug, obj.id],
67+
self.view_name, args=[value.project.slug, value.id],
5068
request=self.context.get('request', None),
5169
format=self.format or self.context.get('format', None))
5270
except NoReverseMatch:
5371
raise Exception('Could not resolve URL for document.')
5472

55-
class ProjectSpecificItemMixin(object):
56-
"""
57-
Sets a restored instance's `project` attribute based on the serializer's
58-
context.
59-
"""
60-
def __init__(self, *args, **kwargs):
61-
super(ProjectSpecificItemMixin, self).__init__(*args, **kwargs)
62-
if self.object is None and 'project' not in self.context:
63-
# FIXME: is this the best error to raise?
64-
raise ValueError(
65-
'Unbound instances of {0} must be instantiated with a context '
66-
'object containing a project, e.g.: '
67-
'{0}(context={{\'project\': project_instance}})'.format(
68-
self.__class__.__name__))
69-
def restore_object(self, attrs, instance=None):
70-
instance = super(ProjectSpecificItemMixin, self).restore_object(attrs, instance)
71-
if not instance.pk:
72-
instance.project = self.context['project']
73-
elif 'project' in self.context and instance.project != self.context['project']:
74-
raise ValueError('Can\'t change project from serializer.')
75-
return instance
76-
77-
class UpdatersField(Field):
73+
class UpdatersField(ReadOnlyField):
7874
read_only = True
79-
def field_to_native(self, obj, field_name):
80-
return [u.username for u in obj.get_all_updaters()]
75+
def get_attribute(self, obj):
76+
return obj.get_all_updaters()
77+
def to_representation(self, value):
78+
return [u.username for u in value]
79+
80+
class MinimalTopicSerializer(ModelSerializer):
81+
url = URLField(lookup_arg_attrs=('project.slug', 'topic_node_id'))
82+
topic_node_id = SerializerMethodField()
83+
class Meta:
84+
model = Topic
85+
fields = ('id', 'topic_node_id', 'preferred_name', 'url',)
86+
def get_topic_node_id(self, obj):
87+
return obj.topic_node_id
8188

8289
class TopicAssignmentField(RelatedField):
90+
default_error_messages = {
91+
'no_match': 'No topic matches this URL.',
92+
'outside_project': 'Related topics must be within the same project.',
93+
'bad_path': 'This URL is not a topic API url.'
94+
}
8395
def __init__(self, *args, **kwargs):
96+
kwargs['queryset'] = Topic.objects.all()
8497
super(TopicAssignmentField, self).__init__(*args, **kwargs)
85-
self.many = True
86-
def format_topic_assignment(self, ta):
87-
url = reverse('api:api-topics-detail',
88-
args=(ta.topic.project.slug, ta.topic.id),
89-
request=self.context['request'])
90-
return {
91-
'id': ta.topic.id,
92-
'preferred_name': ta.topic.preferred_name,
93-
'url': url
94-
}
95-
def field_to_native(self, obj, field_name):
96-
if obj is None:
97-
ret = []
98-
else:
99-
ret = [self.format_topic_assignment(ta) for ta in obj.related_topics.all()]
100-
return ret
101-
def field_from_native(self, data, files, field_name, into):
98+
def get_attribute(self, obj):
99+
return [ta.topic for ta in obj.related_topics.all()]
100+
def to_representation(self, topics):
101+
return [ MinimalTopicSerializer(topic, context=self.context).data
102+
for topic in topics ]
103+
def to_internal_value(self, data):
102104
if self.read_only:
103105
return
104-
into[field_name] = data.get(field_name, [])
106+
return [self._topic_from_url(url) for url in data]
107+
def _topic_from_url(self, url):
108+
try:
109+
match = resolve(url)
110+
except Resolver404:
111+
self.fail('no_match')
112+
113+
if match.view_name != 'api:api-topics-detail':
114+
self.fail('bad_path')
115+
116+
current_project = self.context['request'].project
117+
lookup_project_slug = match.kwargs.pop('project_slug')
118+
if lookup_project_slug != current_project.slug:
119+
self.fail('outside_project')
120+
121+
return self.get_queryset().get(project=current_project, **match.kwargs)
105122

106123
class RelatedTopicSerializerMixin(object):
107124
def save_related_topics(self, obj, topics):
108125
"""
109126
Given an array of names, make sure obj is related to those topics.
110127
"""
111-
to_create = topics[:]
112-
to_delete = []
113-
114-
for assignment in obj.related_topics.select_related('topic').all():
115-
topic_name = assignment.topic.preferred_name
116-
if topic_name in topics:
117-
to_create.remove(topic_name)
118-
else:
119-
to_delete.append(assignment)
120-
121-
for assignment in to_delete:
122-
assignment.delete()
123-
124-
user = self.context['request'].user
125-
project = self.context['request'].project
126-
127-
for topic_name in to_create:
128-
topic = Topic.objects.get_or_create_by_name(
129-
topic_name, project, user)
130-
obj.related_topics.create(topic=topic, creator_id=user.id)
131-
132-
def save_object(self, obj, **kwargs):
133-
# Need to change to allow partial updates, etc.
134-
topics = []
135-
if getattr(obj, '_m2m_data', None):
136-
topics = obj._m2m_data.pop('related_topics')
137-
super(RelatedTopicSerializerMixin, self).save_object(obj, **kwargs)
138-
self.save_related_topics(obj, topics)
128+
rel_topics = obj.related_topics.select_related('topic').all()
129+
130+
new_topics = set(topics)
131+
existing_topics = { ta.topic for ta in rel_topics }
132+
133+
to_create = new_topics.difference(existing_topics)
134+
to_delete = existing_topics.difference(new_topics)
135+
136+
# Delete unused topic assignments
137+
rel_topics.filter(topic__in=to_delete).delete()
138+
139+
# Create new topic assignments
140+
for topic in to_create:
141+
obj.related_topics.create(
142+
topic=topic, creator_id=self.context['request'].user.id)
143+
144+
def create(self, validated_data):
145+
topics = validated_data.pop('related_topics', None)
146+
obj = super(RelatedTopicSerializerMixin, self).create(validated_data)
147+
if topics is not None:
148+
self.save_related_topics(obj, topics)
149+
return obj
150+
def update(self, instance, validated_data):
151+
topics = validated_data.pop('related_topics', None)
152+
super(RelatedTopicSerializerMixin, self).update(instance, validated_data)
153+
if topics is not None:
154+
self.save_related_topics(instance, topics)
155+
return instance

editorsnotes/api/serializers/documents.py

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@
44
from lxml import etree
55
from rest_framework import serializers
66
from rest_framework.reverse import reverse
7+
from rest_framework.validators import UniqueValidator
78

89
from editorsnotes.main.models import Document, Citation, Scan, Transcript
10+
from editorsnotes.main.utils import remove_stray_brs
911

10-
from .base import (RelatedTopicSerializerMixin, ProjectSpecificItemMixin,
12+
from .base import (RelatedTopicSerializerMixin, CurrentProjectDefault,
1113
URLField, ProjectSlugField, HyperlinkedProjectItemField,
1214
TopicAssignmentField)
1315

14-
class ZoteroField(serializers.WritableField):
15-
def to_native(self, zotero_data):
16-
return zotero_data and json.loads(zotero_data,
17-
object_pairs_hook=OrderedDict)
18-
def from_native(self, data):
19-
return data and json.dumps(data)
16+
class ZoteroField(serializers.Field):
17+
def to_representation(self, value):
18+
return value and json.loads(value, object_pairs_hook=OrderedDict)
19+
def to_internal_value(self, data):
20+
return json.dumps(data)
2021

2122
class HyperLinkedImageField(serializers.ImageField):
2223
def to_native(self, value):
@@ -29,52 +30,76 @@ def to_native(self, value):
2930
return ret
3031

3132
class ScanSerializer(serializers.ModelSerializer):
32-
creator = serializers.Field('creator.username')
33+
creator = serializers.ReadOnlyField(source='creator.username')
3334
image = HyperLinkedImageField()
3435
image_thumbnail = HyperLinkedImageField(read_only=True)
3536
class Meta:
3637
model = Scan
3738
fields = ('id', 'image', 'image_thumbnail', 'ordering', 'created',
3839
'creator',)
3940

40-
class DocumentSerializer(RelatedTopicSerializerMixin, ProjectSpecificItemMixin,
41+
class UniqueDocumentDescriptionValidator:
42+
message = u'Document with this description already exists.'
43+
def set_context(self, serializer):
44+
self.instance = getattr(self, 'instance', None)
45+
def __call__(self, attrs):
46+
if self.instance is not None:
47+
description = attrs.get('description', self.instance.description)
48+
else:
49+
description = attrs['description']
50+
51+
project = attrs['project']
52+
qs = Document.objects.filter(
53+
description_digest=Document.hash_description(description),
54+
project=project)
55+
if self.instance is not None:
56+
qs = qs.exclude(id=self.instance.id)
57+
if qs.exists():
58+
raise serializers.ValidationError({ 'description': [self.message] })
59+
60+
class DocumentSerializer(RelatedTopicSerializerMixin,
4161
serializers.ModelSerializer):
4262
url = URLField()
43-
project = ProjectSlugField()
63+
project = ProjectSlugField(default=CurrentProjectDefault())
4464
transcript = serializers.SerializerMethodField('get_transcript_url')
4565
zotero_data = ZoteroField(required=False)
4666
related_topics = TopicAssignmentField()
4767
scans = ScanSerializer(many=True, required=False, read_only=True)
48-
def get_validation_exclusions(self):
49-
# TODO: This can be removed in future versions of django rest framework.
50-
# It's necessary because for the time being, DRF excludes non-required
51-
# fields from validation (not something I find particularly useful, but
52-
# they must've had their reasons...)
53-
exclusions = super(DocumentSerializer, self).get_validation_exclusions()
54-
exclusions.remove('zotero_data')
55-
return exclusions
68+
class Meta:
69+
model = Document
70+
fields = ('id', 'description', 'url', 'project', 'last_updated',
71+
'scans', 'transcript', 'related_topics', 'zotero_data',)
72+
validators = [
73+
UniqueDocumentDescriptionValidator()
74+
]
5675
def get_transcript_url(self, obj):
5776
if not obj.has_transcript():
5877
return None
5978
return reverse('api:api-transcripts-detail',
6079
args=(obj.project.slug, obj.id),
6180
request=self.context.get('request', None))
62-
class Meta:
63-
model = Document
64-
fields = ('id', 'description', 'url', 'project', 'last_updated',
65-
'scans', 'transcript', 'related_topics', 'zotero_data',)
81+
def validate_description(self, value):
82+
description_stripped = Document.strip_description(value)
83+
if not description_stripped:
84+
raise serializer.ValidationError('Field required.')
85+
remove_stray_brs(value)
86+
return value
87+
6688

6789
class TranscriptSerializer(serializers.ModelSerializer):
6890
url = URLField(lookup_arg_attrs=('document.project.slug', 'document.id'))
69-
document = HyperlinkedProjectItemField(
70-
required=True, view_name='api:api-documents-detail')
91+
document = HyperlinkedProjectItemField(view_name='api:api-documents-detail',
92+
queryset=Document.objects,
93+
required=True)
7194
class Meta:
7295
model = Transcript
7396

7497
class CitationSerializer(serializers.ModelSerializer):
7598
url = URLField('api:api-topic-citations-detail',
7699
('content_object.project.slug', 'content_object.topic_node_id', 'id'))
77-
document = HyperlinkedProjectItemField(view_name='api:api-documents-detail')
100+
document = HyperlinkedProjectItemField(view_name='api:api-documents-detail',
101+
queryset=Document.objects,
102+
required=True)
78103
document_description = serializers.SerializerMethodField('get_document_description')
79104
class Meta:
80105
model = Citation

0 commit comments

Comments
 (0)