Skip to content

Commit a62e996

Browse files
committed
Added a recursive object to JSON serializer and a view which will let any logged-in user download a JSON serialized copy of the datatracker information related to his person record. Added information about this, and a link, to the account page. Related to issue ietf-tools#2501.
- Legacy-Id: 15206
1 parent a2e0794 commit a62e996

7 files changed

Lines changed: 324 additions & 69 deletions

File tree

ietf/api/__init__.py

Lines changed: 15 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,84 +5,19 @@
55

66
from django.conf import settings
77
from django.core.exceptions import ObjectDoesNotExist
8-
from django.utils.encoding import force_text
98

109
import debug # pyflakes:ignore
1110

1211
import tastypie
1312
import tastypie.resources
1413
from tastypie.api import Api
1514
from tastypie.bundle import Bundle
16-
from tastypie.serializers import Serializer as BaseSerializer
1715
from tastypie.exceptions import ApiFieldError
16+
from tastypie.serializers import Serializer # pyflakes:ignore (we're re-exporting this)
1817
from tastypie.fields import ApiField
1918

2019
_api_list = []
2120

22-
class ModelResource(tastypie.resources.ModelResource):
23-
def generate_cache_key(self, *args, **kwargs):
24-
"""
25-
Creates a unique-enough cache key.
26-
27-
This is based off the current api_name/resource_name/args/kwargs.
28-
"""
29-
#smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()]
30-
smooshed = urlencode(kwargs)
31-
32-
# Use a list plus a ``.join()`` because it's faster than concatenation.
33-
return "%s:%s:%s:%s" % (self._meta.api_name, self._meta.resource_name, ':'.join(args), smooshed)
34-
35-
class Serializer(BaseSerializer):
36-
def to_html(self, data, options=None):
37-
"""
38-
Reserved for future usage.
39-
40-
The desire is to provide HTML output of a resource, making an API
41-
available to a browser. This is on the TODO list but not currently
42-
implemented.
43-
"""
44-
from django.template.loader import render_to_string
45-
46-
options = options or {}
47-
48-
serialized = self.to_simple_html(data, options)
49-
return render_to_string("api/base.html", {"data": serialized})
50-
51-
def to_simple_html(self, data, options):
52-
"""
53-
"""
54-
from django.template.loader import render_to_string
55-
#
56-
if isinstance(data, (list, tuple)):
57-
return render_to_string("api/listitem.html", {"data": [self.to_simple_html(item, options) for item in data]})
58-
if isinstance(data, dict):
59-
return render_to_string("api/dictitem.html", {"data": dict((key, self.to_simple_html(val, options)) for (key, val) in data.items())})
60-
elif isinstance(data, Bundle):
61-
return render_to_string("api/dictitem.html", {"data":dict((key, self.to_simple_html(val, options)) for (key, val) in data.data.items())})
62-
elif hasattr(data, 'dehydrated_type'):
63-
if getattr(data, 'dehydrated_type', None) == 'related' and data.is_m2m == False:
64-
return render_to_string("api/relitem.html", {"fk": data.fk_resource, "val": self.to_simple_html(data.value, options)})
65-
elif getattr(data, 'dehydrated_type', None) == 'related' and data.is_m2m == True:
66-
render_to_string("api/listitem.html", {"data": [self.to_simple_html(bundle, options) for bundle in data.m2m_bundles]})
67-
else:
68-
return self.to_simple_html(data.value, options)
69-
elif isinstance(data, datetime.datetime):
70-
return self.format_datetime(data)
71-
elif isinstance(data, datetime.date):
72-
return self.format_date(data)
73-
elif isinstance(data, datetime.time):
74-
return self.format_time(data)
75-
elif isinstance(data, bool):
76-
return data
77-
elif isinstance(data, (six.integer_types, float)):
78-
return data
79-
elif data is None:
80-
return None
81-
elif isinstance(data, basestring) and data.startswith("/api/v1/"): # XXX Will not work for Python 3
82-
return render_to_string("api/relitem.html", {"fk": data, "val": data.split('/')[-2]})
83-
else:
84-
return force_text(data)
85-
8621
for _app in settings.INSTALLED_APPS:
8722
_module_dict = globals()
8823
if '.' in _app:
@@ -116,6 +51,20 @@ def autodiscover():
11651
if module_has_submodule(mod, "resources"):
11752
raise
11853

54+
class ModelResource(tastypie.resources.ModelResource):
55+
def generate_cache_key(self, *args, **kwargs):
56+
"""
57+
Creates a unique-enough cache key.
58+
59+
This is based off the current api_name/resource_name/args/kwargs.
60+
"""
61+
#smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()]
62+
smooshed = urlencode(kwargs)
63+
64+
# Use a list plus a ``.join()`` because it's faster than concatenation.
65+
return "%s:%s:%s:%s" % (self._meta.api_name, self._meta.resource_name, ':'.join(args), smooshed)
66+
67+
11968
TIMEDELTA_REGEX = re.compile('^(?P<days>\d+d)?\s?(?P<hours>\d+h)?\s?(?P<minutes>\d+m)?\s?(?P<seconds>\d+s?)$')
12069

12170
class TimedeltaField(ApiField):

ietf/api/serializer.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import hashlib
2+
import json
3+
4+
from django.core.cache import cache
5+
from django.core.exceptions import ObjectDoesNotExist, FieldError
6+
from django.core.serializers.json import Serializer
7+
from django.http import HttpResponse
8+
from django.utils.encoding import smart_text
9+
from django.db.models import Field
10+
from django.db.models.query import QuerySet
11+
from django.db.models.signals import post_save, post_delete, m2m_changed
12+
13+
import debug # pyflakes:ignore
14+
15+
16+
def filter_from_queryargs(request):
17+
#@debug.trace
18+
def fix_ranges(d):
19+
for k,v in d.items():
20+
if v.startswith("[") and v.endswith("]"):
21+
d[k] = [ s for s in v[1:-1].split(",") if s ]
22+
elif "," in v:
23+
d[k] = [ s for s in v.split(",") if s ]
24+
if k.endswith('__in') and not isinstance(d[k], list):
25+
d[k] = [ d[k] ]
26+
return d
27+
def is_ascii(s):
28+
return all(ord(c) < 128 for c in s)
29+
# limit parameter keys to ascii.
30+
params = dict( (k,v) for (k,v) in request.GET.items() if is_ascii(k) )
31+
filter = fix_ranges(dict([(k,params[k]) for k in params.keys() if not k.startswith("not__")]))
32+
exclude = fix_ranges(dict([(k[5:],params[k]) for k in params.keys() if k.startswith("not__")]))
33+
return filter, exclude
34+
35+
def unique_obj_name(obj):
36+
"""Return a unique string representation for an object, based on app, class and ID
37+
"""
38+
app = obj._meta.app_label
39+
model = obj.__class__.__name__.lower()
40+
id = obj.pk
41+
return "%s.%s[%s]" % (app,model,id)
42+
43+
def cached_get(key, calculate_value, timeout=None):
44+
"""Try to get value from cache using key. If no value exists calculate
45+
it by calling calculate_value. Timeout is defined in seconds."""
46+
value = cache.get(key)
47+
if value is None:
48+
value = calculate_value()
49+
cache.set(key, value, timeout)
50+
return value
51+
52+
def model_top_level_cache_key(model):
53+
return model.__module__ + '.' + model._meta.model.__name__
54+
55+
def clear_top_level_cache(sender, instance, *args, **kwargs):
56+
cache.delete(model_top_level_cache_key(instance))
57+
58+
def clear_top_level_cache_m2m(sender, instance, action, reverse, model, *args, **kwargs):
59+
# Purge cache for both models affected and the potentially custom 'through' model
60+
cache.delete_many((
61+
model_top_level_cache_key(instance),
62+
model_top_level_cache_key(model),
63+
model_top_level_cache_key(sender),
64+
))
65+
66+
post_save.connect(clear_top_level_cache, dispatch_uid='clear_top_level_cache')
67+
post_delete.connect(clear_top_level_cache, dispatch_uid='clear_top_level_cache')
68+
m2m_changed.connect(clear_top_level_cache_m2m, dispatch_uid='clear_top_level_cache')
69+
70+
class AdminJsonSerializer(Serializer):
71+
"""
72+
Serializes a QuerySet to Json, with selectable object expansion.
73+
The representation is different from that of the builtin Json
74+
serializer in that there is no separate "model", "pk" and "fields"
75+
entries for each object, instead only the "fields" dictionary is
76+
serialized, and the model is the key of a top-level dictionary
77+
entry which encloses the table serialization:
78+
{
79+
"app.model": {
80+
"1": {
81+
"foo": "1",
82+
"bar": 42,
83+
}
84+
}
85+
}
86+
"""
87+
88+
internal_use_only = False
89+
use_natural_keys = False
90+
91+
def serialize(self, queryset, **options):
92+
qi = options.get('query_info', '')
93+
if len(list(queryset)) == 1:
94+
obj = queryset[0]
95+
key = 'json:%s:%s' % (hashlib.md5(qi).hexdigest(), unique_obj_name(obj))
96+
is_cached = cache.get(model_top_level_cache_key(obj)) is True
97+
if is_cached:
98+
value = cached_get(key, lambda: super(AdminJsonSerializer, self).serialize(queryset, **options))
99+
else:
100+
value = super(AdminJsonSerializer, self).serialize(queryset, **options)
101+
cache.set(key, value)
102+
cache.set(model_top_level_cache_key(obj), True)
103+
return value
104+
else:
105+
return super(AdminJsonSerializer, self).serialize(queryset, **options)
106+
107+
def start_serialization(self):
108+
super(AdminJsonSerializer, self).start_serialization()
109+
self.json_kwargs.pop("expand", None)
110+
self.json_kwargs.pop("query_info", None)
111+
112+
def get_dump_object(self, obj):
113+
return self._current
114+
115+
def end_object(self, obj):
116+
expansions = [ n.split("__")[0] for n in self.options.get('expand', []) if n ]
117+
for name in expansions:
118+
try:
119+
field = getattr(obj, name)
120+
#self._current["_"+name] = smart_text(field)
121+
if not isinstance(field, Field):
122+
options = self.options.copy()
123+
options["expand"] = [ v[len(name)+2:] for v in options["expand"] if v.startswith(name+"__") ]
124+
if hasattr(field, "all"):
125+
if options["expand"]:
126+
# If the following code (doing qs.select_related() is commented out it
127+
# is because it has the unfortunate side effect of changing the json
128+
# rendering of booleans, from 'true/false' to '1/0', but only for the
129+
# models pulled in by select_related(). If that's acceptable, we can
130+
# comment this in again later. (The problem is known, captured in
131+
# Django issue #15040: https://code.djangoproject.com/ticket/15040
132+
self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field.all().select_related() ])
133+
# self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field.all() ])
134+
else:
135+
self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field.all() ])
136+
else:
137+
if callable(field):
138+
try:
139+
field_value = field()
140+
except Exception:
141+
field_value = None
142+
else:
143+
field_value = field
144+
if isinstance(field_value, QuerySet) or isinstance(field_value, list):
145+
self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field_value ])
146+
else:
147+
if hasattr(field_value, "_meta"):
148+
self._current[name] = self.expand_related(field_value, name)
149+
else:
150+
self._current[name] = unicode(field_value)
151+
except ObjectDoesNotExist:
152+
pass
153+
except AttributeError:
154+
names = [f.name for f in obj._meta.get_fields()]
155+
if name in names and hasattr(obj, '%s_set' % name):
156+
related_objects = getattr(obj, '%s_set' % name).all()
157+
if self.options["expand"]:
158+
self._current[name] = dict([(rel.pk, self.expand_related(rel, name)) for rel in related_objects.select_related()])
159+
else:
160+
self._current[name] = dict([(rel.pk, self.expand_related(rel, name)) for rel in related_objects])
161+
else:
162+
raise FieldError("Cannot resolve keyword '%s' into field. "
163+
"Choices are: %s" % (name, ", ".join(names)))
164+
super(AdminJsonSerializer, self).end_object(obj)
165+
166+
def expand_related(self, related, name):
167+
options = self.options.copy()
168+
options["expand"] = [ v[len(name)+2:] for v in options["expand"] if v.startswith(name+"__") ]
169+
bytes = self.__class__().serialize([ related ], **options)
170+
data = json.loads(bytes)[0]
171+
if 'password' in data:
172+
del data['password']
173+
return data
174+
175+
def handle_fk_field(self, obj, field):
176+
try:
177+
related = getattr(obj, field.name)
178+
except ObjectDoesNotExist:
179+
related = None
180+
if related is not None:
181+
if field.name in self.options.get('expand', []):
182+
related = self.expand_related(related, field.name)
183+
elif self.use_natural_keys and hasattr(related, 'natural_key'):
184+
related = related.natural_key()
185+
elif field.remote_field.field_name == related._meta.pk.name:
186+
# Related to remote object via primary key
187+
related = smart_text(related._get_pk_val(), strings_only=True)
188+
else:
189+
# Related to remote object via other field
190+
related = smart_text(getattr(related, field.remote_field.field_name), strings_only=True)
191+
self._current[field.name] = related
192+
193+
def handle_m2m_field(self, obj, field):
194+
if field.remote_field.through._meta.auto_created:
195+
if field.name in self.options.get('expand', []):
196+
m2m_value = lambda value: self.expand_related(value, field.name)
197+
elif self.use_natural_keys and hasattr(field.remote_field.to, 'natural_key'):
198+
m2m_value = lambda value: value.natural_key()
199+
else:
200+
m2m_value = lambda value: smart_text(value._get_pk_val(), strings_only=True)
201+
self._current[field.name] = [m2m_value(related)
202+
for related in getattr(obj, field.name).iterator()]
203+
204+
class JsonExportMixin(object):
205+
"""
206+
Adds JSON export to a DetailView
207+
"""
208+
209+
# def json_object(self, request, object_id, extra_context=None):
210+
# "The json view for an object of this model."
211+
# try:
212+
# obj = self.get_queryset().get(pk=unquote(object_id))
213+
# except self.model.DoesNotExist:
214+
# # Don't raise Http404 just yet, because we haven't checked
215+
# # permissions yet. We don't want an unauthenticated user to be able
216+
# # to determine whether a given object exists.
217+
# obj = None
218+
#
219+
# if obj is None:
220+
# raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_text(self.model._meta.verbose_name), 'key': escape(object_id)})
221+
#
222+
# content_type = 'application/json'
223+
# return HttpResponse(serialize([ obj ], sort_keys=True, indent=3)[2:-2], content_type=content_type)
224+
225+
def json_view(self, request, filter={}, expand=[]):
226+
qfilter, exclude = filter_from_queryargs(request)
227+
for k in qfilter.keys():
228+
if k.startswith("_"):
229+
del qfilter[k]
230+
qfilter.update(filter)
231+
filter = qfilter
232+
key = request.GET.get("_key", "pk")
233+
exp = [ e for e in request.GET.get("_expand", "").split(",") if e ]
234+
for e in exp:
235+
while True:
236+
expand.append(e)
237+
if not "__" in e:
238+
break
239+
e = e.rsplit("__", 1)[0]
240+
#
241+
expand = set(expand)
242+
content_type = 'application/json'
243+
query_info = "%s?%s" % (request.META["PATH_INFO"], request.META["QUERY_STRING"])
244+
try:
245+
qs = self.get_queryset().filter(**filter).exclude(**exclude)
246+
except (FieldError, ValueError) as e:
247+
return HttpResponse(json.dumps({u"error": str(e)}, sort_keys=True, indent=3), content_type=content_type)
248+
try:
249+
if expand:
250+
qs = qs.select_related()
251+
serializer = AdminJsonSerializer()
252+
items = [(getattr(o, key), serializer.serialize([o], expand=expand, query_info=query_info) ) for o in qs ]
253+
qd = dict( ( k, json.loads(v)[0] ) for k,v in items )
254+
except (FieldError, ValueError) as e:
255+
return HttpResponse(json.dumps({u"error": str(e)}, sort_keys=True, indent=3), content_type=content_type)
256+
text = json.dumps({smart_text(self.model._meta): qd}, sort_keys=True, indent=3)
257+
return HttpResponse(text, content_type=content_type)
258+

ietf/api/tests.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
from ietf.group.factories import RoleFactory
2121
from ietf.meeting.factories import MeetingFactory, SessionFactory
2222
from ietf.meeting.test_data import make_meeting_test_data
23+
from ietf.person.factories import PersonFactory
2324
from ietf.person.models import PersonalApiKey
24-
from ietf.utils.test_utils import TestCase
25+
from ietf.utils.test_utils import TestCase, login_testing_unauthorized
2526

2627
OMITTED_APPS = (
2728
'ietf.secr.meetings',
@@ -124,6 +125,17 @@ def test_api_set_session_video_url(self):
124125
self.assertEqual(event.by, recman)
125126

126127

128+
def test_person_export(self):
129+
person = PersonFactory()
130+
url = urlreverse('ietf.api.views.PersonExportView')
131+
login_testing_unauthorized(self, person.user.username, url)
132+
r = self.client.get(url)
133+
jsondata = r.json()
134+
data = jsondata['person.person'][str(person.id)]
135+
self.assertEqual(data['name'], person.name)
136+
self.assertEqual(data['ascii'], person.ascii)
137+
self.assertEqual(data['user']['email'], person.user.email)
138+
127139
class TastypieApiTestCase(ResourceTestCaseMixin, TestCase):
128140
def __init__(self, *args, **kwargs):
129141
self.apps = {}

0 commit comments

Comments
 (0)