from collections import (
deque,
abc,
)
import copy
from functools import (
lru_cache,
partial,
partialmethod
)
import importlib
import json
import logging
import re
import sqlalchemy
from sqlalchemy.orm import load_only
from sqlalchemy.orm.interfaces import (
ONETOMANY,
MANYTOMANY,
MANYTOONE
)
from pyramid.httpexceptions import (
HTTPNotFound,
HTTPForbidden,
HTTPBadRequest,
HTTPConflict,
HTTPUnsupportedMediaType,
HTTPNotAcceptable,
HTTPError,
HTTPFailedDependency,
HTTPInternalServerError,
status_map,
)
from sqlalchemy.orm.relationships import RelationshipProperty
import pyramid_jsonapi
from pyramid_jsonapi.resource import (
ResourceIndicator,
)
from pyramid_jsonapi.permissions import (
PermissionDenied,
PermissionTarget,
Targets,
)
[docs]def make_method(name, api):
settings = api.settings
wf_module = importlib.import_module(
getattr(settings, 'workflow_{}'.format(name))
)
# Set up the stages.
stages = {
'_view_method_name': name,
'validate_request': deque(),
'alter_request': deque(),
'alter_document': deque(),
'validate_response': deque()
}
# stage_order = ['alter_request', 'validate_request', ]
for stage_name in wf_module.stages:
stages[stage_name] = deque()
# stage_order.append(stage_name)
# stage_order.append('alter_results')
# stage_order.append('validate_response')
stages['validate_request'].append(sh_validate_request_headers)
stages['validate_request'].append(sh_validate_request_valid_json)
stages['validate_request'].append(sh_validate_request_common_validity)
stages['validate_request'].append(sh_validate_request_object_exists)
stages['alter_request'].append(sh_alter_request_add_info)
stages['alter_document'].append(sh_alter_document_self_link)
if name.endswith('get'):
stages['alter_document'].append(sh_alter_document_add_returned_count)
if api.settings.debug_meta:
stages['alter_document'].append(sh_alter_document_debug_info)
# Stack the deques.
for stage_name, stage_deque in stages.items():
try:
item = getattr(wf_module, 'stage_' + stage_name)
except AttributeError:
# If there isn't one, move on.
continue
if callable(item):
# If item is callable just append it.
stage_deque.append(item)
else:
# item should be an iterable of callables. Append them.
for handler in item:
stage_deque.append(handler)
# Build a set of expected responses.
ep_dict = api.endpoint_data.endpoints
parts = name.split('_')
if len(parts) == 1:
endpoint = 'item'
http_method = parts[0].upper()
else:
endpoint, http_method = parts
http_method = http_method.upper()
responses = set(
ep_dict['responses'].keys() |
ep_dict['endpoints'][endpoint]['responses'].keys() |
ep_dict['endpoints'][endpoint]['http_methods'][http_method]['responses'].keys()
)
def method(view):
view.pj_shared = SharedState(view)
try:
# execute_stage(view, stages, 'alter_request')
request = execute_stage(
view, stages, 'alter_request', view.request
)
request = execute_stage(
view, stages, 'validate_request', request
)
view.request = request
document = wf_module.workflow(view, stages)
document = execute_stage(
view, stages, 'alter_document', document
)
ret = execute_stage(
view, stages, 'validate_response', document
)
except Exception as exc:
if exc.__class__ not in responses:
logging.exception(
"Invalid exception raised: %s for route_name: %s path: %s",
exc.__class__,
view.request.matched_route.name,
view.request.current_route_path()
)
if isinstance(exc, HTTPError):
if 400 <= int(exc.code) < 500: # pylint:disable=no-member
raise HTTPBadRequest("Unexpected client error: {}".format(exc))
else:
raise HTTPInternalServerError("Unexpected server error.")
raise
# Log any responses that were not expected.
response_class = status_map[view.request.response.status_code]
if response_class not in responses:
logging.error(
"Invalid response: %s for route_name: %s path: %s",
response_class,
view.request.matched_route.name,
view.request.current_route_path()
)
return ret
# Make stages available as an attribute of the method.
method.stages = stages
return method
[docs]def wrapped_query_all(query):
"""
Wrap query.all() so that SQLAlchemy exceptions can be transformed to
http ones.
"""
try:
for obj in query.all():
yield obj
except sqlalchemy.exc.DataError as exc:
raise HTTPBadRequest(str(exc.orig))
[docs]def follow_rel(view, rel_name, include_path=None):
"""
True if rel_name should be followed and added.
"""
include_path = include_path or []
rel_include_path = include_path + [rel_name]
if rel_name not in view.requested_relationships and not view.path_is_included(rel_include_path):
return False
if not view.mapped_info_from_name(rel_name).get('visible', True):
return False
return True
[docs]def partition(items, predicate=bool):
trues, falses = [], []
for item in items:
if predicate(item):
trues.append(item)
else:
falses.append(item)
return (trues, falses)
[docs]def execute_stage(view, stages, stage_name, arg):
for handler in stages[stage_name]:
arg = handler(
# view, arg, None
arg, view,
stage=stage_name,
view_method=stages['_view_method_name'],
)
return arg
[docs]def partition_doc_data(doc_data, partitioner):
if partitioner is None:
return doc_data, []
accepted, rejected = [], []
for item in doc_data:
if partitioner(item, doc_data):
accepted.append(item)
else:
rejected.append(item)
return accepted, rejected
[docs]def shp_get_alter_document(doc, view, stage, view_method):
data = doc['data']
# Make it so that the data part is always a list for later code DRYness.
# We'll put it back the way it was later. Honest ;-).
if isinstance(data, list):
many = True
else:
data = [data]
many = False
# Find the top level filter function to run over data.
try:
data_filter = partial(
view.permission_filter('get', 'item', 'alter_document'),
permission='get',
stage='alter_document',
view_instance=view,
)
except KeyError:
data_filter = None
# Remember what was rejected so it can be removed from included later.
rejected_set = set()
accepted, rejected = partition_doc_data(data, data_filter)
# Filter any related items.
for item in data:
for rel_name, rel_dict in item.get('relationships', {}).items():
rel_data = rel_dict['data']
if isinstance(rel_data, list):
rel_many = True
else:
rel_data = [rel_data]
rel_many = False
rel_view = view.view_instance(view.relationships[rel_name].tgt_class)
try:
rel_filter = partial(
rel_view.permission_filter('get', 'alter_document'),
permission_sought='get',
stage_name='alter_document',
view_instance=view,
)
except KeyError:
rel_filter = None
rel_accepted, rel_rejected = partition_doc_data(rel_data, rel_filter)
rejected_set |= {(item['type'], item['id']) for item in rel_rejected}
if rel_many:
rel_dict['data'] = rel_accepted
else:
try:
rel_dict['data'] = rel_accepted[0]
except IndexError:
rel_dict['data'] = None
# Time to do what we promised and put scalars back.
if many:
doc['data'] = accepted
else:
try:
doc['data'] = accepted[0]
except IndexError:
if rejected:
raise(HTTPNotFound('Object not found.'))
else:
doc['data'] = None
# Remove any rejected items from included.
included = [
item for item in doc.get('included', {})
if (item['type'], item['id']) not in rejected_set
]
doc['included'] = included
return doc
[docs]def allowed_rel_changes(rel, view, stage, obj_data, perm):
"""
Authorise write action on a relationship.
"""
# Check if action is allowed in the forward direction.
rel_pf = view.permission_filter(perm, Targets.relationship, stage)
rel_target = PermissionTarget(Targets.relationship, rel.name)
rel_dict = obj_data['relationships'][rel.name]
src_obj_data = copy.deepcopy(obj_data)
src_obj_data['relationships'] = {}
tgt_ris = rel_dict['data'] if rel.to_many else [rel_dict['data']]
allowed_forward_ris = []
for tgt_ri in tgt_ris:
rel_data = [tgt_ri] if rel.to_many else tgt_ri
src_obj_data['relationships'] = {rel.name: {'data': rel_data}}
if rel_pf(src_obj_data, rel_target):
allowed_forward_ris.append(tgt_ri)
if not allowed_forward_ris:
# Nothing was allowed.
return False
# Look to see if the *other end* of the relationship is to_one (in which
# case we need PATCH permission to that rel in order to set it to this
# to add or delete this object).
# object or None) or to_many (in which case we need perm permission in order
mirror_rel = rel.mirror_relationship
if not mirror_rel:
# There is no mirror relationship so we can't authorise reverse actions.
return allowed_forward_ris if rel.to_many else allowed_forward_ris[0]
rel_view = mirror_rel.view_class(view.request)
mirror_target = PermissionTarget(Targets.relationship, mirror_rel.name)
if mirror_rel.to_many and rel.to_one:
new_rel_data = allowed_forward_ris[0]
# Many to one.
# If obj.rel currently points to an object then we need to DELETE from
# old_rel_obj.mirror_rel.
if view.obj_id is None:
old_rel_obj = None
else:
# Will raise HTTPNotFound if there isn't an object with id
# view.obj_id but we want that to bubble up.
old_rel_obj = getattr(view.get_item(), rel.name)
if old_rel_obj:
# check permission to delete obj from old_rel_obj.mirror_rel
if not rel_view.permission_filter(
'delete', Targets.relationship, stage
)(
{
'type': rel_view.collection_name,
'id': str(rel_view.id_col(old_rel_obj)),
'relationships': {
mirror_rel.name: {
'data': [src_obj_data],
}
}
},
mirror_target
):
return False
# Need permission to POST to new_obj.mirror_rel
# rel_data is the ri of new object.
if rel_view.permission_filter(
'post', Targets.relationship, stage
)(
{
'type': rel_view.collection_name,
'id': new_rel_data['id'],
'relationships': {
mirror_rel.name: {
'data': [src_obj_data],
}
}
},
mirror_target
):
return new_rel_data
return False
elif mirror_rel.to_one and rel.to_many:
patch_allowed_ris = []
# One to many.
for ri in allowed_forward_ris:
# Need patch on new_rel_obj.mirror_rel
if perm == 'delete':
mirror_data = None
elif perm == 'post':
mirror_data = src_obj_data
if rel_view.permission_filter(
'patch', Targets.relationship, stage
)(
{
'type': ri['type'],
'id': ri['id'],
'relationships': {
mirror_rel.name: {
'data': mirror_data,
}
}
},
mirror_target
):
patch_allowed_ris.append(ri)
if perm == 'delete':
return patch_allowed_ris if patch_allowed_ris else False
# Must be a post now. We need to check if we are allowed to delete
# each ri from old_obj.
allowed_ris = []
for rel_ri in patch_allowed_ris:
rel_obj = rel_view.get_item(rel_ri['id'])
old_obj = getattr(rel_obj, mirror_rel.name)
if not old_obj:
allowed_ris.append(rel_ri)
continue
old_obj_ri = {
'type': view.collection_name,
'id': str(view.id_col(old_obj)),
}
obj_rep_for_perm = {
'type': old_obj_ri['type'],
'id': old_obj_ri['id'],
'relationships': {
rel.name: {
'data': [rel_ri],
}
}
}
if view.permission_filter(
'delete', Targets.relationship, stage
)(obj_rep_for_perm, rel_target):
allowed_ris.append(rel_ri)
return allowed_ris if allowed_ris else False
elif mirror_rel.to_many and rel.to_many:
# Many to many.
allowed_ris = []
for fwd_ri in allowed_forward_ris:
# need the same permission on mirror_rel.
if rel_view.permission_filter(
perm, Targets.relationship, stage
)(
{
'type': fwd_ri['type'],
'id': fwd_ri['id'],
'relationships': {
mirror_rel.name: {
'data': [src_obj_data],
}
}
},
mirror_target
):
allowed_ris.append(fwd_ri)
return allowed_ris if allowed_ris else False
else:
# One to one.
pass
[docs]def shp_collection_post_alter_request(request, view, stage, view_method):
# Make sure there is a permission filter registered.
col_pf = view.permission_filter('post', Targets.collection, stage)
obj_data = request.json_body['data']
allowed = col_pf(
obj_data,
target=PermissionTarget(Targets.collection, name=view.collection_name),
)
if not allowed.id:
# Straight up forbidden to create object.
raise HTTPForbidden(
f"No permission to POST object:\n\n{request.json_body['data']}"
)
reject_atts = set()
for att_name in list(obj_data.get('attributes', {}).keys()):
if att_name not in allowed.attributes:
del(obj_data['attributes'][att_name])
reject_atts.add(att_name)
# TODO: alternatively raise HTTPForbidden?
view.pj_shared.rejected.reject_attributes(
(obj_data['type'], obj_data.get('id')),
reject_atts,
f"Attribute rejected during POST to {view.collection_name}"
)
rel_names = list(obj_data.get('relationships', {}).keys())
reject_rels = set()
accept_rels = dict()
for rel_name in rel_names:
rel_data = allowed_rel_changes(
view.relationships[rel_name], view, stage, obj_data, 'post'
)
if rel_data is False: # is False because None and [] are valid.
# Record, rather than delete, rejected rels because we're in the
# middle of a loop over them.
reject_rels.add(rel_name)
else:
obj_data['relationships'][rel_name]['data'] = rel_data
# Deal with rejected rels.
for rel_name in reject_rels:
del(obj_data['relationships'][rel_name])
view.pj_shared.rejected.reject_relationships(
(obj_data['type'], obj_data.get('id')),
reject_rels,
"permission denied"
)
request.body = json.dumps({'data': obj_data}).encode()
return request
[docs]def shp_relationships_post_alter_request(request, view, stage, view_method):
rel = view.rel
# Construct obj_data in the form that authz needs.
obj_data = {
'type': view.collection_name, 'id': view.obj_id,
'relationships': {
rel.name: {
'data': request.json_body['data']
}
}
}
# Need permission to POST to obj.rel.
rel_data = allowed_rel_changes(rel, view, stage, obj_data, 'post')
if rel_data is False:
raise HTTPForbidden(
f"No permission to POST to {obj_data['type']}/{obj_data['id']}.{rel.name}"
)
request.body = json.dumps({'data': rel_data}).encode()
return request
[docs]def rel_patch_to_actions(view, src_id, rel, rel_patch_data):
"""
Split a patch to a to_many rel into post and delete. Return a to_one patch.
"""
if not rel.to_many:
return {'patch': rel_patch_data}
rel_view = view.api.view_classes[rel.tgt_class]
new_rel_ris = {
ResourceIndicator.from_dict(ri) for ri in rel_patch_data
}
current_obj = view.get_item(src_id)
current_rel_ris = current_related_ris(view, src_id, rel)
# Construct posts as a list so we preserve the order in rel_patch_data.
post_set = new_rel_ris - current_rel_ris
post_rel_ris = [
ri for ri in rel_patch_data
if ResourceIndicator.from_dict(ri) in post_set
]
# There's no order divinable for deletes so just construct from the sets.
delete_rel_ris = [
ri.to_dict() for ri in (current_rel_ris - new_rel_ris)
]
return {'post': post_rel_ris, 'delete': delete_rel_ris}
[docs]def patch_rel_new_data(view, src_type, src_id, rel, rel_dict, stage):
obj_rep_for_rel = {
'type': src_type, 'id': src_id,
'relationships': {
rel.name: {},
}
}
new_data = [
ri.to_dict() for ri in current_related_ris(view, src_id, rel)
]
something_changed = False
for hmethod, ris in rel_patch_to_actions(
view, src_id, rel, rel_dict['data']
).items():
obj_rep_for_rel['relationships'][rel.name]['data'] = ris
rel_changes = allowed_rel_changes(
rel, view, stage, obj_rep_for_rel, hmethod
)
if rel_changes is False:
# is False because None and [] are valid.
# raise PermissionDenied(f"No permission to change {rel.name}")
continue
else:
if hmethod == 'patch':
something_changed = True
new_data = [rel_changes]
elif hmethod == 'post':
something_changed |= bool(rel_changes)
new_data.extend(rel_changes)
elif hmethod == 'delete':
something_changed |= bool(rel_changes)
new_data = [ri for ri in new_data if ri not in rel_changes]
if not something_changed:
raise PermissionDenied(f"No permission to PATCH {src_type}/{src_id}.{rel.name}")
if rel.to_one:
new_data = new_data[0]
return new_data
[docs]def shp_patch_alter_request(request, view, stage, view_method):
# Make sure there is a permission filter registered.
item_pf = view.permission_filter('patch', Targets.item, stage)
obj_data = request.json_body['data']
allowed = item_pf(
obj_data,
target=PermissionTarget(Targets.item),
)
if not allowed.id:
# Straight up forbidden to create object.
raise HTTPForbidden(
f"No permission to PATCH object {obj_data['type']}/{view.obj_id}."
)
reject_atts = set()
for att_name in list(obj_data.get('attributes', {}).keys()):
if att_name not in allowed.attributes:
del(obj_data['attributes'][att_name])
reject_atts.add(att_name)
# TODO: alternatively raise HTTPForbidden?
view.pj_shared.rejected.reject_attributes(
(obj_data['type'], obj_data.get('id')),
reject_atts,
f"Attribute rejected during PATCH of {obj_data['type']}/{view.obj_id}."
)
reject_rels = set()
accept_rels = dict()
for rel_name, rel_dict in obj_data.get('relationships', {}).items():
rel = view.relationships[rel_name]
try:
new_data = patch_rel_new_data(
view, view.collection_name, view.obj_id, rel, rel_dict, stage
)
except PermissionDenied:
reject_rels.add(rel_name)
continue
# if not rel.to_many:
# new_data = new_data[0]
obj_data['relationships'][rel_name]['data'] = new_data
# Deal with rejected rels.
for rel_name in reject_rels:
del(obj_data['relationships'][rel_name])
view.pj_shared.rejected.reject_relationships(
(obj_data['type'], obj_data.get('id')),
reject_rels,
"permission denied"
)
request.body = json.dumps({'data': obj_data}).encode()
return request
[docs]def shp_relationships_patch_alter_request(request, view, stage, view_method):
rel = view.rel
# Construct obj_data in the form that authz needs.
obj_data = {
'type': view.collection_name, 'id': view.obj_id,
'relationships': {
rel.name: {
'data': request.json_body['data']
}
}
}
# Need permission to PATCH obj.rel.
try:
new_data = patch_rel_new_data(
view, view.collection_name, view.obj_id, rel, obj_data['relationships'][rel.name], stage
)
except PermissionDenied:
raise HTTPForbidden(
f"No permission to PATCH {obj_data['type']}/{obj_data['id']}.{rel.name}"
)
obj_data['relationships'][rel.name]['data'] = new_data
request.body = json.dumps({'data': new_data}).encode()
return request
[docs]def get_item(view, item_or_id=None):
"""Wrapper around view.get_item() to allow passing an item or an id."""
if item_or_id is None:
item_or_id = view.obj_id
if isinstance(item_or_id, view.model):
return item_or_id
else:
return view.get_item(item_or_id)
[docs]def shp_delete_alter_request(request, view, stage, view_method):
item_pf = view.permission_filter('delete', Targets.item, stage)
this_item = view.get_item()
this_ro = ResultObject(view, this_item)
this_data = this_ro.serialise()
allowed = item_pf(this_data, target=PermissionTarget(Targets.item))
if not allowed.id:
# Straight up forbidden to create object.
raise HTTPForbidden(
f"No permission to DELETE object {this_data['type']}/{view.obj_id}."
)
return request
[docs]def shp_relationships_delete_alter_request(request, view, stage, view_method):
rel = view.rel
# Construct obj_data in the form that authz needs.
obj_data = {
'type': view.collection_name, 'id': view.obj_id,
'relationships': {
rel.name: {
'data': request.json_body['data']
}
}
}
# Need permission to POST to obj.rel.
rel_data = allowed_rel_changes(rel, view, stage, obj_data, 'delete')
if rel_data is False:
raise HTTPForbidden(
f"No permission to DELETE from {obj_data['type']}/{obj_data['id']}.{rel.name}"
)
request.body = json.dumps({'data': rel_data}).encode()
return request
permission_handlers = {
'item_get': {
'alter_document': shp_get_alter_document,
},
'collection_get': {
'alter_document': shp_get_alter_document,
},
'related_get': {
'alter_document': shp_get_alter_document,
},
'relationships_get': {
'alter_document': shp_get_alter_document,
},
'collection_post': {
'alter_request': shp_collection_post_alter_request,
},
'relationships_post': {
'alter_request': shp_relationships_post_alter_request,
},
'item_patch': {
'alter_request': shp_patch_alter_request,
},
'relationships_patch': {
'alter_request': shp_relationships_patch_alter_request,
},
'item_delete': {
'alter_request': shp_delete_alter_request,
},
'relationships_delete': {
'alter_request': shp_relationships_delete_alter_request,
}
}
[docs]def permission_handler(endpoint_name, stage_name):
return permission_handlers[endpoint_name][stage_name]
[docs]@lru_cache
def get_jsonapi_accepts(request):
"""Return a set of all 'application/vnd.api' parts of the accept
header.
"""
accepts = re.split(
r',\s*',
request.headers.get('accept', '')
)
return {
a for a in accepts
if a.startswith('application/vnd.api')
}
[docs]def sh_validate_request_valid_json(request, view, stage, view_method):
"""Check that the body of any request is valid JSON.
Raises:
HTTPBadRequest
"""
if request.content_length:
try:
request.json_body
except ValueError:
raise HTTPBadRequest("Body is not valid JSON.")
return request
[docs]def sh_validate_request_common_validity(request, view, stage, view_method):
"""Perform common request validity checks."""
if request.content_length and view.api.settings.schema_validation:
# Validate request JSON against the JSONAPI jsonschema
view.api.metadata.JSONSchema.validate(request.json_body, request.method)
# Spec says throw BadRequest if any include paths reference non
# existent attributes or relationships.
if view.bad_include_paths:
raise HTTPBadRequest(
"Bad include paths {}".format(
view.bad_include_paths
)
)
# Spec says set Content-Type to application/vnd.api+json.
request.response.content_type = 'application/vnd.api+json'
return request
[docs]def sh_validate_request_object_exists(request, view, stage, view_method):
"""Make sure that id exists in collection for all urls specifying an id."""
if view.obj_id is not None:
if not view.object_exists(view.obj_id):
raise HTTPNotFound('No item {} in {}'.format(view.obj_id, view.collection_name))
return request
[docs]def sh_alter_document_self_link(doc, view, stage, view_method):
"""Include a self link unless the method is PATCH."""
if view.request.method != 'PATCH':
doc.update_child('links', {'self': view.request.url})
return doc
[docs]def sh_alter_document_debug_info(doc, view, stage, view_method):
"""Potentially add some debug information."""
debug = {
'accept_header': {
a: None for a in get_jsonapi_accepts(view.request)
},
'qinfo_page':
view.collection_query_info(view.request)['_page'],
'atts': {k: None for k in view.attributes.keys()},
'includes': {
k: None for k in view.requested_include_names()
}
}
doc.update_child('meta', {'debug': debug})
return doc
[docs]def sh_alter_document_add_returned_count(doc, view, stage, view_method):
"""Add the returned count to meta."""
# Don't add a returned count unless we're returning an array of objects.
if not isinstance(doc['data'], abc.Sequence):
return doc
try:
meta = doc['meta']
except KeyError:
meta = doc['meta'] = {}
try:
results = meta['results']
except KeyError:
results = meta['results'] = {}
results['returned'] = len(doc['data'])
return doc
[docs]def sh_alter_document_add_denied(doc, view, stage, view_method):
try:
meta = doc['meta']
except KeyError:
meta = doc['meta'] = {}
try:
results = meta['results']
except KeyError:
results = meta['results'] = {}
rejected_dict = view.pj_shared.rejected.rejected_dict
results['denied'] = len(rejected_dict['objects'])
meta['rejected'] = rejected_dict
# delete(results['available'])
return doc
[docs]def sh_alter_request_add_info(request, view, stage, view_method):
"""Add information commonly used in view operations."""
# Extract id and relationship from route, if provided
view.obj_id = view.request.matchdict.get('id', None)
view.not_found_message = f'No item {view.obj_id} in {view.collection_name}'
view.relname = view.request.matchdict.get('relationship', None)
if view.relname:
# Gather relationship info
mapper = sqlalchemy.inspect(view.model).mapper
try:
view.rel = view.relationships[view.relname]
except KeyError:
raise HTTPNotFound('No relationship {} in collection {}'.format(
view.relname,
view.collection_name
))
view.rel_class = view.rel.tgt_class
view.rel_view = view.view_instance(view.rel_class)
return request
[docs]class ResultObject:
def __init__(self, view, object, related=None):
self.view = view
self.object = object
self.related = related or {}
if object is None:
self.obj_id = None
else:
self.obj_id = self.view.id_col(self.object)
self.url = self.view.request.route_url(
self.view.api.endpoint_data.make_route_name(
self.view.collection_name, suffix='item'
),
**{'id': self.obj_id}
)
self.attribute_mask = set(self.view.requested_attributes)
self.rel_mask = set(self.view.requested_relationships)
self._included_dict = None
[docs] def serialise(self):
# An object of 'None' is a special case.
if self.object is None:
return None
atts = {
key: getattr(self.object, key)
for key in self.attribute_mask
if self.view.mapped_info_from_name(key).get('visible', True)
}
rels = {
rel_name: res.rel_dict(
rel=self.view.relationships[rel_name],
rel_name=rel_name,
parent_url=self.url
)
for rel_name, res in self.related.items()
if rel_name in self.rel_mask
}
return {
'type': self.view.collection_name,
'id': str(self.obj_id),
'attributes': atts,
'links': {'self': self.url},
'relationships': rels,
}
[docs] def to_dict(self):
if self.object is None:
return None
return {
'type': self.view.collection_name,
'id': str(self.obj_id),
'attributes': {
key: getattr(self.object, key) for key in self.view.all_attributes
}
}
[docs] def identifier(self):
# An object of 'None' is a special case.
if self.object is None:
return None
return {
'type': self.view.collection_name,
'id': str(self.obj_id)
}
@property
def tuple_identifier(self):
if self.object is None:
return None
return (
self.view.collection_name,
str(self.obj_id)
)
@property
def str_identifier(self):
if self.object is None:
return None
return f'{self.view.collection_name}-::-{self.obj_id}'
@property
def included_dict(self):
incd = {}
for rel_name, res in self.related.items():
if not res.is_included:
continue
incd.update(res.included_dict)
return incd
[docs]class Results:
def __init__(self, view, objects=None, many=True, count=None, limit=None, is_included=False, is_top=False, not_found_message='Object not found.'):
self.view = view
self.objects = objects or []
self.rejected_objects = []
self.many = many
self.count = count
self.limit = limit
self.is_included = is_included
self.is_top = is_top
self.not_found_message = not_found_message
self._meta = None
self._included_dict = None
self._flag_filtered = False
[docs] def serialise(self, identifiers=False):
doc = Doc()
if self.many:
# doc.collection = True
doc['links'] = self.view.pagination_links(count=self.count)
if identifiers:
doc['data'] = self.identifiers()
else:
doc['data'] = self.data()
doc['included'] = self.included()
doc['meta'] = self.meta
return doc
[docs] def serialise_object_with(self, method_name):
data = [getattr(o, method_name)() for o in self.objects]
if self.many:
return data
else:
try:
return data[0]
except IndexError:
return None
@property
def meta(self):
if self._meta is None:
self._meta = self.compute_meta()
return self._meta
[docs] def data(self):
return self.serialise_object_with('serialise')
[docs] def identifiers(self):
return self.serialise_object_with('identifier')
[docs] def rel_dict(self, rel, rel_name, parent_url):
rd = {
'data': self.identifiers(),
'links': {
'self': '{}/relationships/{}'.format(parent_url, rel_name),
'related': '{}/{}'.format(parent_url, rel_name)
},
'meta': {
'direction': rel.direction.name,
}
}
if self.many:
rd['meta']['results'] = {}
rd['meta']['results']['available'] = self.count
rd['meta']['results']['limit'] = self.limit
rd['meta']['results']['returned'] = len(rd['data'])
return rd
[docs] def included(self):
return [o.serialise() for o in self.included_dict.values()]
@property
def included_dict(self):
included_dict = {}
for o in self.objects:
if not self.is_top:
included_dict[(self.view.collection_name, o.obj_id)] = o
included_dict.update(o.included_dict)
return included_dict
[docs] def filter(self, predicate, reason='Permission denied', force_rerun=False):
# if self._flag_filtered and not force_rerun:
# return
accepted = []
for obj in self.objects:
pred = self.view.permission_to_dict(predicate(obj))
if pred['id']:
accepted.append(obj)
reject_atts = obj.attribute_mask - pred['attributes']
obj.attribute_mask &= pred['attributes']
# record rejected atts
self.view.pj_shared.rejected.reject_attributes(
obj.tuple_identifier,
reject_atts,
reason,
)
reject_rels = obj.rel_mask - pred['relationships']
obj.rel_mask &= pred['relationships']
# record rejected rels
self.view.pj_shared.rejected.reject_relationships(
obj.tuple_identifier,
reject_rels,
reason,
)
else:
self.rejected_objects.append(obj)
self.view.pj_shared.rejected.reject_object(obj.tuple_identifier, reason)
self.objects = accepted
self._flag_filtered = True
[docs]class Doc(dict):
[docs] def update_child(self, key, value):
try:
self[key].update(value)
except KeyError:
self[key] = value
[docs]class SharedState():
def __init__(self, view, request=None, results=None, document=None, rejected=None):
self.view = view
self.request = request
self.results = results
self.document = document
self.rejected = rejected or Rejected(view)
[docs]class Rejected():
def __init__(self, view, rejected=None):
self.view = view
self.rejected = rejected or {
'objects': {},
'attributes': {},
'relationships': {},
}
[docs] def reject_object(self, identifier, reason):
self.rejected['objects'][identifier] = reason
def _reject_multiple(self, identifier, things, reason, category):
if not things:
return
new = {t: reason for t in things}
try:
self.rejected[category][identifier].update(new)
except KeyError:
self.rejected[category][identifier] = new
reject_attributes = partialmethod(_reject_multiple, category='attributes')
reject_relationships = partialmethod(_reject_multiple, category='relationships')
[docs] def identifier_to_str(self, identifier):
return f'{identifier[0]}::{identifier[1]}'
@property
def rejected_dict(self):
ret = {}
for part in ['objects', 'attributes', 'relationships']:
ret[part] = {
self.identifier_to_str(k): v for k, v in self.rejected[part].items()
}
return ret