Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
50 changes: 50 additions & 0 deletions tests/test_permission_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,53 @@ def test_multiput_with_deletions_no_perm(self):
self.assertEquals(403, res.status_code)

country.refresh_from_db()


class TestColumnScoping(TestCase):
def setUp(self):
super().setUp()

u = User(username='testuser_for_not_all_fields', is_active=True, is_superuser=False)
u.set_password('test')
u.save()

self.client = Client()
r = self.client.login(username='testuser_for_not_all_fields', password='test')
self.assertTrue(r)

self.zoo = Zoo(name='Artis')
self.zoo.save()


def test_column_scoping_excludes_columns(self):
res = self.client.get('/zoo/{}/'.format(self.zoo.id))
self.assertEqual(res.status_code, 200)

columns = jsonloads(res.content)['data'].keys()

for field in ['name', 'founding_date', 'django_picture']:
self.zoo._meta.get_field(field) # check if those fields exist, otherwise throw error
self.assertTrue(field not in columns)

for annotation in ['zoo_name']:
self.assertTrue(annotation not in columns)

for property in ['animal_count']:
self.assertTrue(property not in columns)


def test_column_scoping_includes_columns(self):
res = self.client.get('/zoo/{}/'.format(self.zoo.id))
self.assertEqual(res.status_code, 200)

columns = jsonloads(res.content)['data'].keys()

for field in ['id', 'floor_plan']:
self.zoo._meta.get_field(field) # check if those fields exist, otherwise throw error
self.assertTrue(field in columns)

for annotation in ['another_zoo_name']:
self.assertTrue(annotation in columns)

for property in ['another_animal_count']:
self.assertTrue(property in columns)
10 changes: 10 additions & 0 deletions tests/testapp/models/zoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,23 @@ class Zoo(BinderModel):

binder_picture_custom_extensions = BinderImageField(allowed_extensions=['png'], blank=True, null=True)


class Annotations:
zoo_name = models.F('name') # simple alias for testing scoping on annotations
another_zoo_name = models.F('name') # simple alias for testing scoping on annotations


def __str__(self):
return 'zoo %d: %s' % (self.pk, self.name)

@property
def animal_count(self):
return self.animals.count()

@property
def another_animal_count(self):
return self.animals.count()


def clean(self):
if self.name == 'very_special_forbidden_zoo_name':
Expand Down
11 changes: 11 additions & 0 deletions tests/testapp/views/zoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def _require_model_perm(self, perm_type, request, pk=None):
return ['all']
elif perm_type == 'view' and request.user.username == 'testuser_for_bad_q_filter':
return ['bad_q_filter']
elif perm_type == 'view' and request.user.username == 'testuser_for_not_all_fields':
return ['not_all_fields']
else:
model = self.perms_via if hasattr(self, 'perms_via') else self.model
perm = '{}.{}_{}'.format(model._meta.app_label, perm_type, model.__name__.lower())
Expand All @@ -44,3 +46,12 @@ def _scope_view_bad_q_filter(self, request):
return Q(animals__id__in=Animal.objects.all())
# Correct version of filter:
# return Zoo.objects.filter(animals__id__in=Animal.objects.all())

def _scope_view_not_all_fields(self, request):
# expose only certain columns
columns = {
'fields': ['id', 'floor_plan'],
'properties': ['another_animal_count'],
'annotations': ['another_zoo_name'],
}
return Zoo.objects.all(), columns