Commit 94af16aa by Clinton Blackburn

Merge pull request #68 from edx/clintonb/catalog-user-api

Added support for setting Catalog viewers via API
parents ee3bdac1 e809ea25
from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
...@@ -6,6 +7,8 @@ from course_discovery.apps.course_metadata.models import ( ...@@ -6,6 +7,8 @@ from course_discovery.apps.course_metadata.models import (
Course, CourseRun, Image, Organization, Person, Prerequisite, Seat, Subject, Video Course, CourseRun, Image, Organization, Person, Prerequisite, Seat, Subject, Video
) )
User = get_user_model()
class TimestampModelSerializer(serializers.ModelSerializer): class TimestampModelSerializer(serializers.ModelSerializer):
modified = serializers.DateTimeField() modified = serializers.DateTimeField()
...@@ -85,9 +88,21 @@ class OrganizationSerializer(serializers.ModelSerializer): ...@@ -85,9 +88,21 @@ class OrganizationSerializer(serializers.ModelSerializer):
class CatalogSerializer(serializers.ModelSerializer): class CatalogSerializer(serializers.ModelSerializer):
courses_count = serializers.IntegerField(read_only=True, help_text=_('Number of courses contained in this catalog'))
viewers = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all(), many=True,
allow_null=True, allow_empty=True, required=False,
help_text=_('Usernames of users with explicit access to view this catalog'))
def create(self, validated_data):
# Set viewers after the model has been saved
viewers = validated_data.pop('viewers')
instance = super(CatalogSerializer, self).create(validated_data)
instance.viewers = viewers
return instance
class Meta(object): class Meta(object):
model = Catalog model = Catalog
fields = ('id', 'name', 'query', 'courses_count',) fields = ('id', 'name', 'query', 'courses_count', 'viewers')
class CourseRunSerializer(TimestampModelSerializer): class CourseRunSerializer(TimestampModelSerializer):
......
...@@ -9,6 +9,7 @@ from course_discovery.apps.api.serializers import( ...@@ -9,6 +9,7 @@ from course_discovery.apps.api.serializers import(
PersonSerializer, PersonSerializer,
) )
from course_discovery.apps.catalogs.tests.factories import CatalogFactory from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.course_metadata.tests.factories import ( from course_discovery.apps.course_metadata.tests.factories import (
CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory, CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory,
ImageFactory, VideoFactory, OrganizationFactory, PersonFactory, SeatFactory ImageFactory, VideoFactory, OrganizationFactory, PersonFactory, SeatFactory
...@@ -21,7 +22,8 @@ def json_date_format(datetime_obj): ...@@ -21,7 +22,8 @@ def json_date_format(datetime_obj):
class CatalogSerializerTests(TestCase): class CatalogSerializerTests(TestCase):
def test_data(self): def test_data(self):
catalog = CatalogFactory(query='*:*') # We intentionally use a query for all Courses. user = UserFactory()
catalog = CatalogFactory(query='*:*', viewers=[user]) # We intentionally use a query for all Courses.
courses = CourseFactory.create_batch(10) courses = CourseFactory.create_batch(10)
serializer = CatalogSerializer(catalog) serializer = CatalogSerializer(catalog)
...@@ -29,7 +31,8 @@ class CatalogSerializerTests(TestCase): ...@@ -29,7 +31,8 @@ class CatalogSerializerTests(TestCase):
'id': catalog.id, 'id': catalog.id,
'name': catalog.name, 'name': catalog.name,
'query': catalog.query, 'query': catalog.query,
'courses_count': len(courses) 'courses_count': len(courses),
'viewers': [user.username]
} }
self.assertDictEqual(serializer.data, expected) self.assertDictEqual(serializer.data, expected)
......
...@@ -35,9 +35,11 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi ...@@ -35,9 +35,11 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi
def assert_catalog_created(self, **headers): def assert_catalog_created(self, **headers):
name = 'The Kitchen Sink' name = 'The Kitchen Sink'
query = '*.*' query = '*.*'
viewer = UserFactory()
data = { data = {
'name': name, 'name': name,
'query': query 'query': query,
'viewers': [viewer.username]
} }
response = self.client.post(self.catalog_list_url, data, format='json', **headers) response = self.client.post(self.catalog_list_url, data, format='json', **headers)
...@@ -47,6 +49,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi ...@@ -47,6 +49,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi
self.assertDictEqual(response.data, self.serialize_catalog(catalog)) self.assertDictEqual(response.data, self.serialize_catalog(catalog))
self.assertEqual(catalog.name, name) self.assertEqual(catalog.name, name)
self.assertEqual(catalog.query, query) self.assertEqual(catalog.query, query)
self.assertListEqual(list(catalog.viewers), [viewer])
def grant_catalog_permission_to_user(self, user, action, catalog=None): def grant_catalog_permission_to_user(self, user, action, catalog=None):
""" Grant the user access to view `self.catalog`. """ """ Grant the user access to view `self.catalog`. """
......
...@@ -39,7 +39,16 @@ class CatalogViewSet(viewsets.ModelViewSet): ...@@ -39,7 +39,16 @@ class CatalogViewSet(viewsets.ModelViewSet):
return super(CatalogViewSet, self).destroy(request, *args, **kwargs) return super(CatalogViewSet, self).destroy(request, *args, **kwargs)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" Retrieve a list of all catalogs. """ """ Retrieve a list of all catalogs.
---
parameters:
- name: username
description: User whose catalogs should be retrieved.
required: false
type: string
paramType: query
multiple: false
"""
return super(CatalogViewSet, self).list(request, *args, **kwargs) return super(CatalogViewSet, self).list(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
......
from collections import Iterable
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from guardian.shortcuts import get_users_with_perms
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from course_discovery.apps.core.mixins import ModelPermissionsMixin from course_discovery.apps.core.mixins import ModelPermissionsMixin
...@@ -8,6 +11,7 @@ from course_discovery.apps.course_metadata.models import Course ...@@ -8,6 +11,7 @@ from course_discovery.apps.course_metadata.models import Course
class Catalog(ModelPermissionsMixin, TimeStampedModel): class Catalog(ModelPermissionsMixin, TimeStampedModel):
VIEW_PERMISSION = 'view_catalog'
name = models.CharField(max_length=255, null=False, blank=False, help_text=_('Catalog name')) name = models.CharField(max_length=255, null=False, blank=False, help_text=_('Catalog name'))
query = models.TextField(null=False, blank=False, help_text=_('Query to retrieve catalog contents')) query = models.TextField(null=False, blank=False, help_text=_('Query to retrieve catalog contents'))
...@@ -52,6 +56,52 @@ class Catalog(ModelPermissionsMixin, TimeStampedModel): ...@@ -52,6 +56,52 @@ class Catalog(ModelPermissionsMixin, TimeStampedModel):
return contains return contains
@property
def viewers(self):
""" Returns a QuerySet of users who have been granted explicit access to view this Catalog.
Returns:
QuerySet
"""
# NOTE (CCB): This method actually returns any individual User with *any* permission on the object. It is
# safe to assume that those who can create/modify the model can also view it. If that assumption changes,
# change this code!
return get_users_with_perms(self, with_superusers=False, with_group_users=False)
@viewers.setter
def viewers(self, value):
""" Sets the viewers of this model.
This method utilizes Django permissions to set access. Existing user-specific access permissions will be
overwritten. Group permissions will not be affected.
Args:
value (Iterable): Collection of `User` objects.
Raises:
TypeError: The given value is not iterable, or is a string.
Returns:
None
"""
if isinstance(value, str) or not isinstance(value, Iterable):
raise TypeError('Viewers must be a non-string iterable containing User objects.')
new = set(value)
existing = set(self.viewers)
# Remove users who no longer have access
to_be_removed = existing - new
for user in to_be_removed:
user.del_obj_perm(self.VIEW_PERMISSION, self)
# Add new users
new = new - existing
for user in new:
user.add_obj_perm(self.VIEW_PERMISSION, self)
class Meta(TimeStampedModel.Meta): class Meta(TimeStampedModel.Meta):
abstract = False abstract = False
permissions = ( permissions = (
......
...@@ -10,3 +10,8 @@ class CatalogFactory(factory.DjangoModelFactory): ...@@ -10,3 +10,8 @@ class CatalogFactory(factory.DjangoModelFactory):
name = FuzzyText(prefix='catalog-name-') name = FuzzyText(prefix='catalog-name-')
query = '*:*' query = '*:*'
@factory.post_generation
def viewers(self, create, extracted, **kwargs): # pylint: disable=method-hidden,unused-argument
if create and extracted:
self.viewers = extracted
import ddt
from django.test import TestCase from django.test import TestCase
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.catalogs.tests import factories from course_discovery.apps.catalogs.tests import factories
from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.tests.factories import CourseFactory from course_discovery.apps.course_metadata.tests.factories import CourseFactory
@ddt.ddt
class CatalogTests(ElasticsearchTestMixin, TestCase): class CatalogTests(ElasticsearchTestMixin, TestCase):
""" Catalog model tests. """ """ Catalog model tests. """
...@@ -43,3 +47,44 @@ class CatalogTests(ElasticsearchTestMixin, TestCase): ...@@ -43,3 +47,44 @@ class CatalogTests(ElasticsearchTestMixin, TestCase):
CourseFactory() CourseFactory()
CourseFactory(title='ABCDEF') CourseFactory(title='ABCDEF')
self.assertEqual(self.catalog.courses_count, 2) self.assertEqual(self.catalog.courses_count, 2)
def test_get_viewers(self):
""" Verify the method returns a QuerySet of individuals with explicit permission to view a Catalog. """
catalog = self.catalog
self.assertFalse(catalog.viewers.exists()) # pylint:disable=no-member
user = UserFactory()
user.add_obj_perm(Catalog.VIEW_PERMISSION, catalog)
self.assertListEqual(list(catalog.viewers), [user])
def test_set_viewers(self):
""" Verify the method updates the set of users with permission to view a Catalog. """
users = UserFactory.create_batch(2)
permission = 'catalogs.' + Catalog.VIEW_PERMISSION
for user in users:
self.assertFalse(user.has_perm(permission, self.catalog))
# Verify a list of users can be added as viewers
self.catalog.viewers = users
for user in users:
self.assertTrue(user.has_perm(permission, self.catalog))
# Verify existing users, not in the list, have their access revoked.
permitted = users[0]
revoked = users[1]
self.catalog.viewers = [permitted]
self.assertTrue(permitted.has_perm(permission, self.catalog))
self.assertFalse(revoked.has_perm(permission, self.catalog))
# Verify all users have their access revoked when passing in an empty list
self.catalog.viewers = []
for user in users:
self.assertFalse(user.has_perm(permission, self.catalog))
@ddt.data(None, 35, 'a')
def test_set_viewers_with_invalid_argument(self, viewers):
""" Verify the method raises a `TypeError` if the passed value is not iterable, or is a string. """
with self.assertRaises(TypeError) as context:
self.catalog.viewers = viewers
self.assertEqual(context.exception.args[0], 'Viewers must be a non-string iterable containing User objects.')
...@@ -7,6 +7,7 @@ class ModelPermissionsMixin: ...@@ -7,6 +7,7 @@ class ModelPermissionsMixin:
Inheriting models should have the default add, change, and delete permissions, as well as the Inheriting models should have the default add, change, and delete permissions, as well as the
custom "view" permission. custom "view" permission.
""" """
@classmethod @classmethod
def get_permission(cls, action): def get_permission(cls, action):
""" """
...@@ -46,6 +47,14 @@ class ModelPermissionsMixin: ...@@ -46,6 +47,14 @@ class ModelPermissionsMixin:
@authenticated_users @authenticated_users
@allow_staff_or_superuser @allow_staff_or_superuser
def has_object_create_permission(self, request): # pragma: no cover
# NOTE (CCB): This method is solely here to ensure object creation and permissions behave appropriately
# when using the Browseable API. This is not called when making a JSON request.
perm = self.get_permission('add')
return request.user.has_perm(perm, self)
@authenticated_users
@allow_staff_or_superuser
def has_object_destroy_permission(self, request): def has_object_destroy_permission(self, request):
perm = self.get_permission('delete') perm = self.get_permission('delete')
return request.user.has_perm(perm, self) return request.user.has_perm(perm, self)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment