diff --git a/binder/permissions/views.py b/binder/permissions/views.py index 94bf76b5..d452d1d7 100644 --- a/binder/permissions/views.py +++ b/binder/permissions/views.py @@ -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): """ @@ -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 @@ -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 diff --git a/binder/views.py b/binder/views.py index 0072f3f4..c738cd37 100644 --- a/binder/views.py +++ b/binder/views.py @@ -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: @@ -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: @@ -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 diff --git a/tests/test_permission_view.py b/tests/test_permission_view.py index 952958ab..122cb7c4 100644 --- a/tests/test_permission_view.py +++ b/tests/test_permission_view.py @@ -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) diff --git a/tests/testapp/models/zoo.py b/tests/testapp/models/zoo.py index a6b1737c..e3c616f7 100644 --- a/tests/testapp/models/zoo.py +++ b/tests/testapp/models/zoo.py @@ -35,6 +35,12 @@ 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) @@ -42,6 +48,10 @@ def __str__(self): 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': diff --git a/tests/testapp/views/zoo.py b/tests/testapp/views/zoo.py index 9f14bde2..e843d596 100644 --- a/tests/testapp/views/zoo.py +++ b/tests/testapp/views/zoo.py @@ -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()) @@ -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