Skip to content

Commit

Permalink
Sketch: implement scoping on columns
Browse files Browse the repository at this point in the history
Sometimes we only would like to expose only select fields, annotations
and properties to the client depending on their permissions.

This commit represents a sketch of how this could be done.

ref. T33048
  • Loading branch information
nstylo committed Sep 14, 2021
1 parent 671daf6 commit 3cdba94
Show file tree
Hide file tree
Showing 2 changed files with 101 additions and 3 deletions.
81 changes: 81 additions & 0 deletions binder/permissions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ def get_queryset(self, request):
return self.scope_view(request, queryset)


def get_columns(self, request):
fields, annotations, properties = self.scope_columns(request)

if fields is None:
fields = list(self.model._meta.fields)
else:
fields = list(map(self.model._meta.get_field, fields))

properties = properties if properties is not None else self.shown_properties

# TODO: At this point we would like to not use None anymore but instead
# use collections. Annotations are might still be None at this point.
return fields, annotations, properties



def _require_model_perm(self, perm_type, request, pk=None):
"""
Expand Down Expand Up @@ -266,6 +281,65 @@ def scope_change_list(self, request, objects, values):



def scope_columns(self, request):
"""
Each view scope may optionally declare which columns (fields, annotations, properties)
ought to be exposed to client. So view scope functions may return a tuple of (rows, columns)
instead of rows only. Columns are specified like so:
{
'fields': ['id', 'name', ...] | None,
'annotations': ['derived_name', 'bsn', ...] | None,
'properties': ['amount', ...] | None,
}
Where 'None' means, that there is scoping being performed on that column type.
If multiple functions with scoped columns exist, we take the set union.
"""

# helper function to take the set union of columns
def append_columns(columns, new_columns):
if new_columns is None:
return columns
if columns is None:
columns = set()
return columns | set(new_columns)

scopes = self._require_model_perm('view', request)

fields = None # this is equivalent to all fields
annotations = None # this is equivalent to all annotations
properties = None # this is equivalent to all properties

for s in scopes:
scope_name = '_scope_view_{}'.format(s)
scope_func = getattr(self, scope_name, None)
if scope_func is None:
raise UnexpectedScopeException(
'Scope {} is not implemented for model {}'.format(scope_name, self.model))

result = scope_func(request)

# ignore scope functions which do not scope columns
# i.e. they do not return a tuple of length two
if isinstance(result, tuple):
if len(result) < 2:
continue

# TODO: This could be more DRY, but its readable
columns = result[1]
new_fields = columns.get('fields')
new_annotations = columns.get('annotations')
new_properties = columns.get('properties')

fields = append_columns(fields, new_fields)
annotations = append_columns(annotations, new_annotations)
properties = append_columns(properties, new_properties)

return fields, annotations, properties



def scope_view(self, request, queryset):
"""
Performs the scopes for a get request
Expand All @@ -280,6 +354,13 @@ def scope_view(self, request, queryset):
raise UnexpectedScopeException(
'Scope {} is not implemented for model {}'.format(scope_name, self.model))
query_or_q = scope_func(request)

# view scoping may describe scoping of columns. In this case
# the return type is a tuple and we only have to consider the
# first argument
if isinstance(query_or_q, tuple):
query_or_q = query_or_q[0]

# Allow either a ORM filter query manager or a Q object.
# Q objects generate more efficient queries (so we don't
# get an "id IN (subquery)"), but query managers allow
Expand Down
23 changes: 20 additions & 3 deletions binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,14 +473,24 @@ def _get_objs(self, queryset, request, annotations=None):
datas_by_id = {} # Save datas so we can annotate m2m fields later (avoiding a query)
objs_by_id = {} # Same for original objects

# get scoped fields, properties and annotations
fields_scoped, annotations_scoped, properties_scoped = self.get_columns(request)

# Serialize the objects!
if self.shown_fields is None:
fields = [f for f in self.model._meta.fields if f.name not in self.hidden_fields]
fields = [f for f in fields_scoped if f.name not in self.hidden_fields]
else:
fields = [f for f in self.model._meta.fields if f.name in self.shown_fields]
fields = [f for f in fields_scoped if f.name in self.shown_fields]

if annotations is None:
annotations = set(self.annotations(request))

# from the set of annotations remove the ones which are
# hidden by scoping. TODO: perhaps accessing disallowed
# annotations should throw 403 much alike row scoping.
if annotations_scoped is not None:
annotations &= annotations_scoped

if self.shown_annotations is None:
annotations -= set(self.hidden_annotations)
else:
Expand Down Expand Up @@ -518,7 +528,7 @@ def _get_objs(self, queryset, request, annotations=None):
for a in annotations:
data[a] = getattr(obj, a)

for prop in self.shown_properties:
for prop in properties_scoped:
data[prop] = getattr(obj, prop)

if self.model._meta.pk.name in data:
Expand Down Expand Up @@ -1191,6 +1201,13 @@ def get_queryset(self, request):
return self.model.objects.all()


def get_columns(self, request):
# TODO: annotations are currently just None here which is not very
# expressive. But annotations are a little more complicated than
# fields and properties.
return list(self.model._meta.fields), None, self.shown_properties



def order_by(self, queryset, request):
#### order_by
Expand Down

0 comments on commit 3cdba94

Please sign in to comment.