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 #2501.

- Legacy-Id: 15206
This commit is contained in:
Henrik Levkowetz 2018-06-04 13:06:47 +00:00
parent a2e0794d91
commit a62e9964a5
7 changed files with 324 additions and 69 deletions

View file

@ -5,7 +5,6 @@ from urllib import urlencode
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.utils.encoding import force_text
import debug # pyflakes:ignore
@ -13,76 +12,12 @@ import tastypie
import tastypie.resources
from tastypie.api import Api
from tastypie.bundle import Bundle
from tastypie.serializers import Serializer as BaseSerializer
from tastypie.exceptions import ApiFieldError
from tastypie.serializers import Serializer # pyflakes:ignore (we're re-exporting this)
from tastypie.fields import ApiField
_api_list = []
class ModelResource(tastypie.resources.ModelResource):
def generate_cache_key(self, *args, **kwargs):
"""
Creates a unique-enough cache key.
This is based off the current api_name/resource_name/args/kwargs.
"""
#smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()]
smooshed = urlencode(kwargs)
# Use a list plus a ``.join()`` because it's faster than concatenation.
return "%s:%s:%s:%s" % (self._meta.api_name, self._meta.resource_name, ':'.join(args), smooshed)
class Serializer(BaseSerializer):
def to_html(self, data, options=None):
"""
Reserved for future usage.
The desire is to provide HTML output of a resource, making an API
available to a browser. This is on the TODO list but not currently
implemented.
"""
from django.template.loader import render_to_string
options = options or {}
serialized = self.to_simple_html(data, options)
return render_to_string("api/base.html", {"data": serialized})
def to_simple_html(self, data, options):
"""
"""
from django.template.loader import render_to_string
#
if isinstance(data, (list, tuple)):
return render_to_string("api/listitem.html", {"data": [self.to_simple_html(item, options) for item in data]})
if isinstance(data, dict):
return render_to_string("api/dictitem.html", {"data": dict((key, self.to_simple_html(val, options)) for (key, val) in data.items())})
elif isinstance(data, Bundle):
return render_to_string("api/dictitem.html", {"data":dict((key, self.to_simple_html(val, options)) for (key, val) in data.data.items())})
elif hasattr(data, 'dehydrated_type'):
if getattr(data, 'dehydrated_type', None) == 'related' and data.is_m2m == False:
return render_to_string("api/relitem.html", {"fk": data.fk_resource, "val": self.to_simple_html(data.value, options)})
elif getattr(data, 'dehydrated_type', None) == 'related' and data.is_m2m == True:
render_to_string("api/listitem.html", {"data": [self.to_simple_html(bundle, options) for bundle in data.m2m_bundles]})
else:
return self.to_simple_html(data.value, options)
elif isinstance(data, datetime.datetime):
return self.format_datetime(data)
elif isinstance(data, datetime.date):
return self.format_date(data)
elif isinstance(data, datetime.time):
return self.format_time(data)
elif isinstance(data, bool):
return data
elif isinstance(data, (six.integer_types, float)):
return data
elif data is None:
return None
elif isinstance(data, basestring) and data.startswith("/api/v1/"): # XXX Will not work for Python 3
return render_to_string("api/relitem.html", {"fk": data, "val": data.split('/')[-2]})
else:
return force_text(data)
for _app in settings.INSTALLED_APPS:
_module_dict = globals()
if '.' in _app:
@ -116,6 +51,20 @@ def autodiscover():
if module_has_submodule(mod, "resources"):
raise
class ModelResource(tastypie.resources.ModelResource):
def generate_cache_key(self, *args, **kwargs):
"""
Creates a unique-enough cache key.
This is based off the current api_name/resource_name/args/kwargs.
"""
#smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()]
smooshed = urlencode(kwargs)
# Use a list plus a ``.join()`` because it's faster than concatenation.
return "%s:%s:%s:%s" % (self._meta.api_name, self._meta.resource_name, ':'.join(args), smooshed)
TIMEDELTA_REGEX = re.compile('^(?P<days>\d+d)?\s?(?P<hours>\d+h)?\s?(?P<minutes>\d+m)?\s?(?P<seconds>\d+s?)$')
class TimedeltaField(ApiField):

258
ietf/api/serializer.py Normal file
View file

@ -0,0 +1,258 @@
import hashlib
import json
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, FieldError
from django.core.serializers.json import Serializer
from django.http import HttpResponse
from django.utils.encoding import smart_text
from django.db.models import Field
from django.db.models.query import QuerySet
from django.db.models.signals import post_save, post_delete, m2m_changed
import debug # pyflakes:ignore
def filter_from_queryargs(request):
#@debug.trace
def fix_ranges(d):
for k,v in d.items():
if v.startswith("[") and v.endswith("]"):
d[k] = [ s for s in v[1:-1].split(",") if s ]
elif "," in v:
d[k] = [ s for s in v.split(",") if s ]
if k.endswith('__in') and not isinstance(d[k], list):
d[k] = [ d[k] ]
return d
def is_ascii(s):
return all(ord(c) < 128 for c in s)
# limit parameter keys to ascii.
params = dict( (k,v) for (k,v) in request.GET.items() if is_ascii(k) )
filter = fix_ranges(dict([(k,params[k]) for k in params.keys() if not k.startswith("not__")]))
exclude = fix_ranges(dict([(k[5:],params[k]) for k in params.keys() if k.startswith("not__")]))
return filter, exclude
def unique_obj_name(obj):
"""Return a unique string representation for an object, based on app, class and ID
"""
app = obj._meta.app_label
model = obj.__class__.__name__.lower()
id = obj.pk
return "%s.%s[%s]" % (app,model,id)
def cached_get(key, calculate_value, timeout=None):
"""Try to get value from cache using key. If no value exists calculate
it by calling calculate_value. Timeout is defined in seconds."""
value = cache.get(key)
if value is None:
value = calculate_value()
cache.set(key, value, timeout)
return value
def model_top_level_cache_key(model):
return model.__module__ + '.' + model._meta.model.__name__
def clear_top_level_cache(sender, instance, *args, **kwargs):
cache.delete(model_top_level_cache_key(instance))
def clear_top_level_cache_m2m(sender, instance, action, reverse, model, *args, **kwargs):
# Purge cache for both models affected and the potentially custom 'through' model
cache.delete_many((
model_top_level_cache_key(instance),
model_top_level_cache_key(model),
model_top_level_cache_key(sender),
))
post_save.connect(clear_top_level_cache, dispatch_uid='clear_top_level_cache')
post_delete.connect(clear_top_level_cache, dispatch_uid='clear_top_level_cache')
m2m_changed.connect(clear_top_level_cache_m2m, dispatch_uid='clear_top_level_cache')
class AdminJsonSerializer(Serializer):
"""
Serializes a QuerySet to Json, with selectable object expansion.
The representation is different from that of the builtin Json
serializer in that there is no separate "model", "pk" and "fields"
entries for each object, instead only the "fields" dictionary is
serialized, and the model is the key of a top-level dictionary
entry which encloses the table serialization:
{
"app.model": {
"1": {
"foo": "1",
"bar": 42,
}
}
}
"""
internal_use_only = False
use_natural_keys = False
def serialize(self, queryset, **options):
qi = options.get('query_info', '')
if len(list(queryset)) == 1:
obj = queryset[0]
key = 'json:%s:%s' % (hashlib.md5(qi).hexdigest(), unique_obj_name(obj))
is_cached = cache.get(model_top_level_cache_key(obj)) is True
if is_cached:
value = cached_get(key, lambda: super(AdminJsonSerializer, self).serialize(queryset, **options))
else:
value = super(AdminJsonSerializer, self).serialize(queryset, **options)
cache.set(key, value)
cache.set(model_top_level_cache_key(obj), True)
return value
else:
return super(AdminJsonSerializer, self).serialize(queryset, **options)
def start_serialization(self):
super(AdminJsonSerializer, self).start_serialization()
self.json_kwargs.pop("expand", None)
self.json_kwargs.pop("query_info", None)
def get_dump_object(self, obj):
return self._current
def end_object(self, obj):
expansions = [ n.split("__")[0] for n in self.options.get('expand', []) if n ]
for name in expansions:
try:
field = getattr(obj, name)
#self._current["_"+name] = smart_text(field)
if not isinstance(field, Field):
options = self.options.copy()
options["expand"] = [ v[len(name)+2:] for v in options["expand"] if v.startswith(name+"__") ]
if hasattr(field, "all"):
if options["expand"]:
# If the following code (doing qs.select_related() is commented out it
# is because it has the unfortunate side effect of changing the json
# rendering of booleans, from 'true/false' to '1/0', but only for the
# models pulled in by select_related(). If that's acceptable, we can
# comment this in again later. (The problem is known, captured in
# Django issue #15040: https://code.djangoproject.com/ticket/15040
self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field.all().select_related() ])
# self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field.all() ])
else:
self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field.all() ])
else:
if callable(field):
try:
field_value = field()
except Exception:
field_value = None
else:
field_value = field
if isinstance(field_value, QuerySet) or isinstance(field_value, list):
self._current[name] = dict([ (rel.pk, self.expand_related(rel, name)) for rel in field_value ])
else:
if hasattr(field_value, "_meta"):
self._current[name] = self.expand_related(field_value, name)
else:
self._current[name] = unicode(field_value)
except ObjectDoesNotExist:
pass
except AttributeError:
names = [f.name for f in obj._meta.get_fields()]
if name in names and hasattr(obj, '%s_set' % name):
related_objects = getattr(obj, '%s_set' % name).all()
if self.options["expand"]:
self._current[name] = dict([(rel.pk, self.expand_related(rel, name)) for rel in related_objects.select_related()])
else:
self._current[name] = dict([(rel.pk, self.expand_related(rel, name)) for rel in related_objects])
else:
raise FieldError("Cannot resolve keyword '%s' into field. "
"Choices are: %s" % (name, ", ".join(names)))
super(AdminJsonSerializer, self).end_object(obj)
def expand_related(self, related, name):
options = self.options.copy()
options["expand"] = [ v[len(name)+2:] for v in options["expand"] if v.startswith(name+"__") ]
bytes = self.__class__().serialize([ related ], **options)
data = json.loads(bytes)[0]
if 'password' in data:
del data['password']
return data
def handle_fk_field(self, obj, field):
try:
related = getattr(obj, field.name)
except ObjectDoesNotExist:
related = None
if related is not None:
if field.name in self.options.get('expand', []):
related = self.expand_related(related, field.name)
elif self.use_natural_keys and hasattr(related, 'natural_key'):
related = related.natural_key()
elif field.remote_field.field_name == related._meta.pk.name:
# Related to remote object via primary key
related = smart_text(related._get_pk_val(), strings_only=True)
else:
# Related to remote object via other field
related = smart_text(getattr(related, field.remote_field.field_name), strings_only=True)
self._current[field.name] = related
def handle_m2m_field(self, obj, field):
if field.remote_field.through._meta.auto_created:
if field.name in self.options.get('expand', []):
m2m_value = lambda value: self.expand_related(value, field.name)
elif self.use_natural_keys and hasattr(field.remote_field.to, 'natural_key'):
m2m_value = lambda value: value.natural_key()
else:
m2m_value = lambda value: smart_text(value._get_pk_val(), strings_only=True)
self._current[field.name] = [m2m_value(related)
for related in getattr(obj, field.name).iterator()]
class JsonExportMixin(object):
"""
Adds JSON export to a DetailView
"""
# def json_object(self, request, object_id, extra_context=None):
# "The json view for an object of this model."
# try:
# obj = self.get_queryset().get(pk=unquote(object_id))
# except self.model.DoesNotExist:
# # Don't raise Http404 just yet, because we haven't checked
# # permissions yet. We don't want an unauthenticated user to be able
# # to determine whether a given object exists.
# obj = None
#
# if obj is None:
# 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)})
#
# content_type = 'application/json'
# return HttpResponse(serialize([ obj ], sort_keys=True, indent=3)[2:-2], content_type=content_type)
def json_view(self, request, filter={}, expand=[]):
qfilter, exclude = filter_from_queryargs(request)
for k in qfilter.keys():
if k.startswith("_"):
del qfilter[k]
qfilter.update(filter)
filter = qfilter
key = request.GET.get("_key", "pk")
exp = [ e for e in request.GET.get("_expand", "").split(",") if e ]
for e in exp:
while True:
expand.append(e)
if not "__" in e:
break
e = e.rsplit("__", 1)[0]
#
expand = set(expand)
content_type = 'application/json'
query_info = "%s?%s" % (request.META["PATH_INFO"], request.META["QUERY_STRING"])
try:
qs = self.get_queryset().filter(**filter).exclude(**exclude)
except (FieldError, ValueError) as e:
return HttpResponse(json.dumps({u"error": str(e)}, sort_keys=True, indent=3), content_type=content_type)
try:
if expand:
qs = qs.select_related()
serializer = AdminJsonSerializer()
items = [(getattr(o, key), serializer.serialize([o], expand=expand, query_info=query_info) ) for o in qs ]
qd = dict( ( k, json.loads(v)[0] ) for k,v in items )
except (FieldError, ValueError) as e:
return HttpResponse(json.dumps({u"error": str(e)}, sort_keys=True, indent=3), content_type=content_type)
text = json.dumps({smart_text(self.model._meta): qd}, sort_keys=True, indent=3)
return HttpResponse(text, content_type=content_type)

View file

@ -20,8 +20,9 @@ import debug # pyflakes:ignore
from ietf.group.factories import RoleFactory
from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.meeting.test_data import make_meeting_test_data
from ietf.person.factories import PersonFactory
from ietf.person.models import PersonalApiKey
from ietf.utils.test_utils import TestCase
from ietf.utils.test_utils import TestCase, login_testing_unauthorized
OMITTED_APPS = (
'ietf.secr.meetings',
@ -124,6 +125,17 @@ class CustomApiTestCase(TestCase):
self.assertEqual(event.by, recman)
def test_person_export(self):
person = PersonFactory()
url = urlreverse('ietf.api.views.PersonExportView')
login_testing_unauthorized(self, person.user.username, url)
r = self.client.get(url)
jsondata = r.json()
data = jsondata['person.person'][str(person.id)]
self.assertEqual(data['name'], person.name)
self.assertEqual(data['ascii'], person.ascii)
self.assertEqual(data['user']['email'], person.user.email)
class TastypieApiTestCase(ResourceTestCaseMixin, TestCase):
def __init__(self, *args, **kwargs):
self.apps = {}

View file

@ -20,6 +20,7 @@ urlpatterns = [
url(r'^meeting/session/video/url$', meeting_views.api_set_session_video_url),
url(r'^submit/?$', submit_views.api_submit),
url(r'^iesg/position', views_ballot.api_set_position),
url(r'^export/person/$', api_views.PersonExportView.as_view()),
]
# Additional (standard) Tastypie endpoints

View file

@ -6,17 +6,24 @@ from __future__ import unicode_literals
from jwcrypto.jwk import JWK
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.views.generic.detail import DetailView
from tastypie.exceptions import BadRequest
from tastypie.utils.mime import determine_format, build_content_type
from tastypie.utils import is_valid_jsonp_callback_value
from tastypie.serializers import Serializer
import debug # pyflakes:ignore
from ietf.api import Serializer, _api_list
from ietf.person.models import Person
from ietf.api import _api_list
from ietf.api.serializer import JsonExportMixin
def top_level(request):
available_resources = {}
@ -50,3 +57,19 @@ def api_help(request):
key.import_from_pem(settings.API_PUBLIC_KEY_PEM)
return render(request, "api/index.html", {'key': key, 'settings':settings, })
@method_decorator((login_required, gzip_page), name='dispatch')
class PersonExportView(DetailView, JsonExportMixin):
model = Person
def get(self, request):
person = get_object_or_404(self.model, user=request.user)
expand = ['searchrule', 'documentauthor', 'ad_document_set', 'ad_dochistory_set', 'docevent',
'ballotpositiondocevent', 'deletedevent', 'email_set', 'groupevent', 'role', 'rolehistory', 'iprdisclosurebase',
'iprevent', 'liaisonstatementevent', 'whitelisted', 'schedule', 'constraint', 'session', 'message',
'sendqueue', 'nominee', 'topicfeedbacklastseen', 'alias', 'email', 'apikeys', 'personevent',
'reviewersettings', 'reviewsecretarysettings', 'unavailableperiod', 'reviewwish',
'nextreviewerinteam', 'reviewrequest', 'meetingregistration', 'submissionevent', 'preapproval',
'user']
return self.json_view(request, filter={'id':person.id}, expand=expand)

View file

@ -33,6 +33,16 @@
if you wish to update or remove the information.
</p>
<p>
All the information the datatracker has that is coupled to this account and visible
on this page or otherwise related to your work on ietf documents, is also available
to you as a <a href="{% url 'ietf.api.views.PersonExportView' %}">JSON blob</a> when
you are logged in.
</p>
<hr>
<form class="form-horizontal" method="post">
@ -126,6 +136,8 @@
</div>
</div>
</form>
{% endblock %}
{% block js %}

Binary file not shown.