Enforce permissions for content libraries, add REST API to edit perms
This commit is contained in:
@@ -10,7 +10,7 @@ class ContentLibraryPermissionInline(admin.TabularInline):
|
||||
Inline form for a content library's permissions
|
||||
"""
|
||||
model = ContentLibraryPermission
|
||||
raw_id_fields = ("user", )
|
||||
raw_id_fields = ("user", "group", )
|
||||
extra = 0
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ Python API for content libraries.
|
||||
Unless otherwise specified, all APIs in this file deal with the DRAFT version
|
||||
of the content library.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
import attr
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.validators import validate_unicode_slug
|
||||
from django.db import IntegrityError
|
||||
from lxml import etree
|
||||
@@ -18,7 +19,9 @@ import six
|
||||
from xblock.core import XBlock
|
||||
from xblock.exceptions import XBlockNotFoundError
|
||||
|
||||
from openedx.core.djangoapps.content_libraries import permissions
|
||||
from openedx.core.djangoapps.content_libraries.library_bundle import LibraryBundle
|
||||
from openedx.core.djangoapps.content_libraries.models import ContentLibrary, ContentLibraryPermission
|
||||
from openedx.core.djangoapps.xblock.api import get_block_display_name, load_block
|
||||
from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
|
||||
from openedx.core.djangoapps.xblock.runtime.olx_parsing import XBlockInclude
|
||||
@@ -36,7 +39,6 @@ from openedx.core.lib.blockstore_api import (
|
||||
)
|
||||
from openedx.core.djangolib import blockstore_cache
|
||||
from openedx.core.djangolib.blockstore_cache import BundleCache
|
||||
from .models import ContentLibrary, ContentLibraryPermission
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,6 +82,30 @@ class ContentLibraryMetadata(object):
|
||||
# has_unpublished_deletes will be true when the draft version of the library's bundle
|
||||
# contains deletes of any XBlocks that were in the most recently published version
|
||||
has_unpublished_deletes = attr.ib(False)
|
||||
# Allow any user (even unregistered users) to view and interact directly
|
||||
# with this library's content in the LMS
|
||||
allow_public_learning = attr.ib(False)
|
||||
# Allow any user with Studio access to view this library's content in
|
||||
# Studio, use it in their courses, and copy content out of this library.
|
||||
allow_public_read = attr.ib(False)
|
||||
|
||||
|
||||
class AccessLevel(object):
|
||||
""" Enum defining library access levels/permissions """
|
||||
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
|
||||
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
|
||||
READ_LEVEL = ContentLibraryPermission.READ_LEVEL
|
||||
NO_ACCESS = None
|
||||
|
||||
|
||||
@attr.s
|
||||
class ContentLibraryPermissionEntry(object):
|
||||
"""
|
||||
A user or group granted permission to use a content library.
|
||||
"""
|
||||
user = attr.ib(type=AbstractUser, default=None)
|
||||
group = attr.ib(type=Group, default=None)
|
||||
access_level = attr.ib(AccessLevel.NO_ACCESS)
|
||||
|
||||
|
||||
@attr.s
|
||||
@@ -117,23 +143,32 @@ class LibraryXBlockType(object):
|
||||
display_name = attr.ib("")
|
||||
|
||||
|
||||
class AccessLevel(object):
|
||||
""" Enum defining library access levels/permissions """
|
||||
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
|
||||
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
|
||||
READ_LEVEL = ContentLibraryPermission.READ_LEVEL
|
||||
NO_ACCESS = None
|
||||
def list_libraries_for_user(user):
|
||||
"""
|
||||
Lists up to 50 content libraries that the user has permission to view.
|
||||
|
||||
This method makes at least one HTTP call per library so should only be used
|
||||
for development until we have something more efficient.
|
||||
"""
|
||||
qs = ContentLibrary.objects.all()
|
||||
filtered_qs = permissions.perms[permissions.CAN_VIEW_THIS_CONTENT_LIBRARY].filter(user, qs)
|
||||
return [get_library(ref.library_key) for ref in filtered_qs[:50]]
|
||||
|
||||
|
||||
def list_libraries():
|
||||
def require_permission_for_library_key(library_key, user, permission):
|
||||
"""
|
||||
TEMPORARY method for testing. Lists all content libraries.
|
||||
This should be replaced with a method for listing all libraries that belong
|
||||
to a particular user, and/or has permission to view. This method makes at
|
||||
least one HTTP call per library so should only be used for development.
|
||||
Given any of the content library permission strings defined in
|
||||
openedx.core.djangoapps.content_libraries.permissions,
|
||||
check if the given user has that permission for the library with the
|
||||
specified library ID.
|
||||
|
||||
Raises django.core.exceptions.PermissionDenied if the user doesn't have
|
||||
permission.
|
||||
"""
|
||||
refs = ContentLibrary.objects.all()[:1000]
|
||||
return [get_library(ref.library_key) for ref in refs]
|
||||
assert isinstance(library_key, LibraryLocatorV2)
|
||||
library_obj = ContentLibrary.objects.get_by_key(library_key)
|
||||
if not user.has_perm(permission, obj=library_obj):
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
def get_library(library_key):
|
||||
@@ -154,12 +189,14 @@ def get_library(library_key):
|
||||
title=bundle_metadata.title,
|
||||
description=bundle_metadata.description,
|
||||
version=bundle_metadata.latest_version,
|
||||
allow_public_learning=ref.allow_public_learning,
|
||||
allow_public_read=ref.allow_public_read,
|
||||
has_unpublished_changes=has_unpublished_changes,
|
||||
has_unpublished_deletes=has_unpublished_deletes,
|
||||
)
|
||||
|
||||
|
||||
def create_library(collection_uuid, org, slug, title, description):
|
||||
def create_library(collection_uuid, org, slug, title, description, allow_public_learning, allow_public_read):
|
||||
"""
|
||||
Create a new content library.
|
||||
|
||||
@@ -171,6 +208,10 @@ def create_library(collection_uuid, org, slug, title, description):
|
||||
|
||||
description: description of this library
|
||||
|
||||
allow_public_learning: Allow anyone to read/learn from blocks in the LMS
|
||||
|
||||
allow_public_read: Allow anyone to view blocks (including source) in Studio?
|
||||
|
||||
Returns a ContentLibraryMetadata instance.
|
||||
"""
|
||||
assert isinstance(collection_uuid, UUID)
|
||||
@@ -189,8 +230,8 @@ def create_library(collection_uuid, org, slug, title, description):
|
||||
org=org,
|
||||
slug=slug,
|
||||
bundle_uuid=bundle.uuid,
|
||||
allow_public_learning=True,
|
||||
allow_public_read=True,
|
||||
allow_public_learning=allow_public_learning,
|
||||
allow_public_read=allow_public_read,
|
||||
)
|
||||
except IntegrityError:
|
||||
delete_bundle(bundle.uuid)
|
||||
@@ -201,9 +242,22 @@ def create_library(collection_uuid, org, slug, title, description):
|
||||
title=title,
|
||||
description=description,
|
||||
version=0,
|
||||
allow_public_learning=ref.allow_public_learning,
|
||||
allow_public_read=ref.allow_public_read,
|
||||
)
|
||||
|
||||
|
||||
def get_library_team(library_key):
|
||||
"""
|
||||
Get the list of users/groups granted permission to use this library.
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
return [
|
||||
ContentLibraryPermissionEntry(user=entry.user, group=entry.group, access_level=entry.access_level)
|
||||
for entry in ref.permission_grants.all()
|
||||
]
|
||||
|
||||
|
||||
def set_library_user_permissions(library_key, user, access_level):
|
||||
"""
|
||||
Change the specified user's level of access to this library.
|
||||
@@ -212,12 +266,39 @@ def set_library_user_permissions(library_key, user, access_level):
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
if access_level is None:
|
||||
ref.authorized_users.filter(user=user).delete()
|
||||
ref.permission_grants.filter(user=user).delete()
|
||||
else:
|
||||
ContentLibraryPermission.objects.update_or_create(user=user, library=ref, access_level=access_level)
|
||||
ContentLibraryPermission.objects.update_or_create(
|
||||
library=ref,
|
||||
user=user,
|
||||
defaults={"access_level": access_level},
|
||||
)
|
||||
|
||||
|
||||
def update_library(library_key, title=None, description=None):
|
||||
def set_library_group_permissions(library_key, group, access_level):
|
||||
"""
|
||||
Change the specified group's level of access to this library.
|
||||
|
||||
access_level should be one of the AccessLevel values defined above.
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
if access_level is None:
|
||||
ref.permission_grants.filter(group=group).delete()
|
||||
else:
|
||||
ContentLibraryPermission.objects.update_or_create(
|
||||
library=ref,
|
||||
group=group,
|
||||
defaults={"access_level": access_level},
|
||||
)
|
||||
|
||||
|
||||
def update_library(
|
||||
library_key,
|
||||
title=None,
|
||||
description=None,
|
||||
allow_public_learning=None,
|
||||
allow_public_read=None,
|
||||
):
|
||||
"""
|
||||
Update a library's title or description.
|
||||
(Slug cannot be changed as it would break IDs throughout the system.)
|
||||
@@ -225,6 +306,17 @@ def update_library(library_key, title=None, description=None):
|
||||
A value of None means "don't change".
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
# Update MySQL model:
|
||||
changed = False
|
||||
if allow_public_learning is not None:
|
||||
ref.allow_public_learning = allow_public_learning
|
||||
changed = True
|
||||
if allow_public_read is not None:
|
||||
ref.allow_public_read = allow_public_read
|
||||
changed = True
|
||||
if changed:
|
||||
ref.save()
|
||||
# Update Blockstore:
|
||||
fields = {
|
||||
# We don't ever read the "slug" value from the Blockstore bundle, but
|
||||
# we might as well always do our best to keep it in sync with the "slug"
|
||||
|
||||
@@ -4,6 +4,9 @@ Definition of "Library" as a learning context.
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from openedx.core.djangoapps.content_libraries import api, permissions
|
||||
from openedx.core.djangoapps.content_libraries.library_bundle import (
|
||||
LibraryBundle,
|
||||
bundle_uuid_for_library_key,
|
||||
@@ -30,29 +33,45 @@ class LibraryContextImpl(LearningContext):
|
||||
def can_edit_block(self, user, usage_key):
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
specified user (which may be an AnonymousUser) have permission to edit
|
||||
it?
|
||||
specified user have permission to edit it (make changes to the authored
|
||||
data store)?
|
||||
|
||||
user: a Django User object (may be an AnonymousUser)
|
||||
|
||||
usage_key: the UsageKeyV2 subclass used for this learning context
|
||||
|
||||
Must return a boolean.
|
||||
"""
|
||||
try:
|
||||
api.require_permission_for_library_key(usage_key.lib_key, user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
except PermissionDenied:
|
||||
return False
|
||||
def_key = self.definition_for_usage(usage_key)
|
||||
if not def_key:
|
||||
return False
|
||||
# TODO: implement permissions
|
||||
return True
|
||||
|
||||
def can_view_block(self, user, usage_key):
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
specified user (which may be an AnonymousUser) have permission to view
|
||||
it and interact with it (call handlers, save user state, etc.)?
|
||||
specified user have permission to view it and interact with it (call
|
||||
handlers, save user state, etc.)?
|
||||
|
||||
user: a Django User object (may be an AnonymousUser)
|
||||
|
||||
usage_key: the UsageKeyV2 subclass used for this learning context
|
||||
|
||||
Must return a boolean.
|
||||
"""
|
||||
try:
|
||||
api.require_permission_for_library_key(
|
||||
usage_key.lib_key, user, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY,
|
||||
)
|
||||
except PermissionDenied:
|
||||
return False
|
||||
def_key = self.definition_for_usage(usage_key)
|
||||
if not def_key:
|
||||
return False
|
||||
# TODO: implement permissions
|
||||
return True
|
||||
|
||||
def definition_for_usage(self, usage_key, **kwargs):
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.26 on 2019-12-11 19:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0008_alter_user_username_max_length'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('content_libraries', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='contentlibrarypermission',
|
||||
options={'ordering': ('user__username', 'group__name')},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contentlibrary',
|
||||
name='authorized_users',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentlibrarypermission',
|
||||
name='group',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='auth.Group', blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contentlibrarypermission',
|
||||
name='library',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_grants', to='content_libraries.ContentLibrary'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contentlibrarypermission',
|
||||
name='user',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, blank=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contentlibrarypermission',
|
||||
unique_together=set([('library', 'user'), ('library', 'group')]),
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Models for new Content Libraries
|
||||
"""
|
||||
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2
|
||||
from organizations.models import Organization
|
||||
@@ -26,7 +25,6 @@ class ContentLibraryManager(models.Manager):
|
||||
return self.get(org__short_name=library_key.org, slug=library_key.slug)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ContentLibrary(models.Model):
|
||||
"""
|
||||
A Content Library is a collection of content (XBlocks and/or static assets)
|
||||
@@ -68,8 +66,6 @@ class ContentLibrary(models.Model):
|
||||
"""),
|
||||
)
|
||||
|
||||
authorized_users = models.ManyToManyField(User, through='ContentLibraryPermission')
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Content Libraries"
|
||||
unique_together = ("org", "slug")
|
||||
@@ -85,14 +81,15 @@ class ContentLibrary(models.Model):
|
||||
return "ContentLibrary ({})".format(six.text_type(self.library_key))
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ContentLibraryPermission(models.Model):
|
||||
"""
|
||||
Row recording permissions for a content library
|
||||
"""
|
||||
library = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
# TODO: allow permissions to be assign to a group, not just a user
|
||||
library = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE, related_name="permission_grants")
|
||||
# One of the following must be set (but not both):
|
||||
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.CASCADE)
|
||||
# What level of access is granted to the above user or group:
|
||||
ADMIN_LEVEL = 'admin'
|
||||
AUTHOR_LEVEL = 'author'
|
||||
READ_LEVEL = 'read'
|
||||
@@ -103,5 +100,25 @@ class ContentLibraryPermission(models.Model):
|
||||
)
|
||||
access_level = models.CharField(max_length=30, choices=ACCESS_LEVEL_CHOICES)
|
||||
|
||||
class Meta:
|
||||
ordering = ('user__username', 'group__name')
|
||||
unique_together = [
|
||||
('library', 'user'),
|
||||
('library', 'group'),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs): # pylint: disable=arguments-differ
|
||||
"""
|
||||
Validate any constraints on the model.
|
||||
|
||||
We can remove this and replace it with a proper database constraint
|
||||
once we're upgraded to Django 2.2+
|
||||
"""
|
||||
# if both are nonexistent or both are existing, error
|
||||
if (not self.user) == (not self.group):
|
||||
raise ValidationError(_("One and only one of 'user' and 'group' must be set."))
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return "ContentLibraryPermission ({} for {})".format(self.access_level, self.user.username)
|
||||
who = self.user.username if self.user else self.group.name
|
||||
return "ContentLibraryPermission ({} for {})".format(self.access_level, who)
|
||||
|
||||
98
openedx/core/djangoapps/content_libraries/permissions.py
Normal file
98
openedx/core/djangoapps/content_libraries/permissions.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Permissions for Content Libraries (v2, Blockstore-based)
|
||||
"""
|
||||
from bridgekeeper import perms, rules
|
||||
from bridgekeeper.rules import Attribute, ManyRelation, Relation, in_current_groups
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
|
||||
|
||||
# Is the user active (and their email verified)?
|
||||
is_user_active = rules.is_authenticated & rules.is_active
|
||||
# Is the user global staff?
|
||||
is_global_staff = is_user_active & rules.is_staff
|
||||
|
||||
# Helper rules used to define the permissions below
|
||||
|
||||
# Does the user have at least read permission for the specified library?
|
||||
has_explicit_read_permission_for_library = (
|
||||
ManyRelation(
|
||||
# In newer versions of bridgekeeper, the 1st and 3rd arguments below aren't needed.
|
||||
'permission_grants', 'contentlibrarypermission', ContentLibraryPermission,
|
||||
Attribute('user', lambda user: user) | Relation('group', Group, in_current_groups)
|
||||
)
|
||||
# We don't check 'access_level' here because all access levels grant read permission
|
||||
)
|
||||
# Does the user have at least author permission for the specified library?
|
||||
has_explicit_author_permission_for_library = (
|
||||
ManyRelation(
|
||||
'permission_grants', 'contentlibrarypermission', ContentLibraryPermission,
|
||||
(Attribute('user', lambda user: user) | Relation('group', Group, in_current_groups)) & (
|
||||
Attribute('access_level', ContentLibraryPermission.AUTHOR_LEVEL) |
|
||||
Attribute('access_level', ContentLibraryPermission.ADMIN_LEVEL)
|
||||
)
|
||||
)
|
||||
)
|
||||
# Does the user have admin permission for the specified library?
|
||||
has_explicit_admin_permission_for_library = (
|
||||
ManyRelation(
|
||||
'permission_grants', 'contentlibrarypermission', ContentLibraryPermission,
|
||||
(Attribute('user', lambda user: user) | Relation('group', Group, in_current_groups)) &
|
||||
Attribute('access_level', ContentLibraryPermission.ADMIN_LEVEL)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
########################### Permissions ###########################
|
||||
|
||||
# Is the user allowed to view XBlocks from the specified content library
|
||||
# directly in the LMS, and interact with them?
|
||||
# Note there is no is_authenticated/is_active check for this one - we allow
|
||||
# anonymous users to learn if the library allows public learning.
|
||||
CAN_LEARN_FROM_THIS_CONTENT_LIBRARY = 'content_libraries.learn_from_library'
|
||||
perms[CAN_LEARN_FROM_THIS_CONTENT_LIBRARY] = (
|
||||
# Global staff can learn from any library:
|
||||
is_global_staff |
|
||||
# Regular users can learn if the library allows public learning:
|
||||
Attribute('allow_public_learning', True) |
|
||||
# Users/groups who are explicitly granted permission can learn from the library:
|
||||
has_explicit_read_permission_for_library
|
||||
)
|
||||
|
||||
# Is the user allowed to create content libraries?
|
||||
CAN_CREATE_CONTENT_LIBRARY = 'content_libraries.create_library'
|
||||
perms[CAN_CREATE_CONTENT_LIBRARY] = is_user_active
|
||||
|
||||
# Is the user allowed to view the specified content library in Studio,
|
||||
# including to view the raw OLX and asset files?
|
||||
CAN_VIEW_THIS_CONTENT_LIBRARY = 'content_libraries.view_library'
|
||||
perms[CAN_VIEW_THIS_CONTENT_LIBRARY] = is_user_active & (
|
||||
# Global staff can access any library
|
||||
is_global_staff |
|
||||
# Some libraries allow anyone to view them in Studio:
|
||||
Attribute('allow_public_read', True) |
|
||||
# Otherwise the user must be part of the library's team
|
||||
has_explicit_read_permission_for_library
|
||||
)
|
||||
|
||||
# Is the user allowed to edit the specified content library?
|
||||
CAN_EDIT_THIS_CONTENT_LIBRARY = 'content_libraries.edit_library'
|
||||
perms[CAN_EDIT_THIS_CONTENT_LIBRARY] = is_user_active & (
|
||||
is_global_staff | has_explicit_author_permission_for_library
|
||||
)
|
||||
|
||||
# Is the user allowed to view the users/permissions of the specified content library?
|
||||
CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM = 'content_libraries.view_library_team'
|
||||
perms[CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM] = perms[CAN_EDIT_THIS_CONTENT_LIBRARY]
|
||||
|
||||
# Is the user allowed to edit the users/permissions of the specified content library?
|
||||
CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM = 'content_libraries.edit_library_team'
|
||||
perms[CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM] = is_user_active & (
|
||||
is_global_staff | has_explicit_admin_permission_for_library
|
||||
)
|
||||
|
||||
# Is the user allowed to delete the specified content library?
|
||||
CAN_DELETE_THIS_CONTENT_LIBRARY = 'content_libraries.delete_library'
|
||||
perms[CAN_DELETE_THIS_CONTENT_LIBRARY] = is_user_active & (
|
||||
is_global_staff | has_explicit_admin_permission_for_library
|
||||
)
|
||||
@@ -2,11 +2,10 @@
|
||||
Serializers for the content libraries REST API
|
||||
"""
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
|
||||
from django.core.validators import validate_unicode_slug
|
||||
from rest_framework import serializers
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
|
||||
from openedx.core.lib import blockstore_api
|
||||
|
||||
|
||||
@@ -16,7 +15,9 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
# We rename the primary key field to "id" in the REST API since API clients
|
||||
# often implement magic functionality for fields with that name, and "key"
|
||||
# is a reserved prop name in React
|
||||
# is a reserved prop name in React. This 'id' field is a string that
|
||||
# begins with 'lib:'. (The numeric ID of the ContentLibrary object in MySQL
|
||||
# is not exposed via this API.)
|
||||
id = serializers.CharField(source="key", read_only=True)
|
||||
org = serializers.SlugField(source="key.org")
|
||||
slug = serializers.CharField(source="key.slug", validators=(validate_unicode_slug, ))
|
||||
@@ -25,6 +26,8 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
|
||||
title = serializers.CharField()
|
||||
description = serializers.CharField(allow_blank=True)
|
||||
version = serializers.IntegerField(read_only=True)
|
||||
allow_public_learning = serializers.BooleanField(default=False)
|
||||
allow_public_read = serializers.BooleanField(default=False)
|
||||
has_unpublished_changes = serializers.BooleanField(read_only=True)
|
||||
has_unpublished_deletes = serializers.BooleanField(read_only=True)
|
||||
|
||||
@@ -36,6 +39,27 @@ class ContentLibraryUpdateSerializer(serializers.Serializer):
|
||||
# These are the only fields that support changes:
|
||||
title = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
allow_public_learning = serializers.BooleanField()
|
||||
allow_public_read = serializers.BooleanField()
|
||||
|
||||
|
||||
class ContentLibraryPermissionLevelSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for the "Access Level" of a ContentLibraryPermission object.
|
||||
|
||||
This is used when updating a user or group's permissions re some content
|
||||
library.
|
||||
"""
|
||||
access_level = serializers.ChoiceField(choices=ContentLibraryPermission.ACCESS_LEVEL_CHOICES)
|
||||
|
||||
|
||||
class ContentLibraryPermissionSerializer(ContentLibraryPermissionLevelSerializer):
|
||||
"""
|
||||
Serializer for a ContentLibraryPermission object, which grants either a user
|
||||
or a group permission to view a content library.
|
||||
"""
|
||||
user_id = serializers.IntegerField(source="user.id", allow_null=True)
|
||||
group_name = serializers.CharField(source="group.name", allow_null=True, allow_blank=False, default=None)
|
||||
|
||||
|
||||
class LibraryXBlockMetadataSerializer(serializers.Serializer):
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
"""
|
||||
Tests for Blockstore-based Content Libraries
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from organizations.models import Organization
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
import six
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
@@ -22,6 +22,9 @@ URL_LIB_DETAIL = URL_PREFIX + '{lib_key}/' # Get data about a library, update o
|
||||
URL_LIB_BLOCK_TYPES = URL_LIB_DETAIL + 'block_types/' # Get the list of XBlock types that can be added to this library
|
||||
URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library
|
||||
URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one
|
||||
URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library
|
||||
URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{user_id}/' # Add/edit/remove a user's permission to use this library
|
||||
URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library
|
||||
URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it
|
||||
URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock
|
||||
URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock
|
||||
@@ -76,8 +79,21 @@ class ContentLibrariesRestApiTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(ContentLibrariesRestApiTest, self).setUp()
|
||||
self.clients_by_user = {}
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
|
||||
# Assertions
|
||||
|
||||
def assertDictContainsEntries(self, big_dict, subset_dict):
|
||||
"""
|
||||
Assert that the first dict contains at least all of the same entries as
|
||||
the second dict.
|
||||
|
||||
Like python 2's assertDictContainsSubset, but with the arguments in the
|
||||
correct order.
|
||||
"""
|
||||
self.assertGreaterEqual(big_dict.items(), subset_dict.items())
|
||||
|
||||
# API helpers
|
||||
|
||||
def _api(self, method, url, data, expect_response):
|
||||
@@ -91,6 +107,19 @@ class ContentLibrariesRestApiTest(APITestCase):
|
||||
)
|
||||
return response.data
|
||||
|
||||
@contextmanager
|
||||
def as_user(self, user):
|
||||
"""
|
||||
Context manager to call the REST API as a user other than self.user
|
||||
"""
|
||||
old_client = self.client
|
||||
if user not in self.clients_by_user:
|
||||
client = self.clients_by_user[user] = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
self.client = self.clients_by_user[user] # pylint: disable=attribute-defined-outside-init
|
||||
yield
|
||||
self.client = old_client # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
def _create_library(self, slug, title, description="", expect_response=200):
|
||||
""" Create a library """
|
||||
return self._api('post', URL_LIB_CREATE, {
|
||||
@@ -105,25 +134,45 @@ class ContentLibrariesRestApiTest(APITestCase):
|
||||
""" Get a library """
|
||||
return self._api('get', URL_LIB_DETAIL.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _update_library(self, lib_key, **data):
|
||||
def _update_library(self, lib_key, expect_response=200, **data):
|
||||
""" Update an existing library """
|
||||
return self._api('patch', URL_LIB_DETAIL.format(lib_key=lib_key), data=data, expect_response=200)
|
||||
return self._api('patch', URL_LIB_DETAIL.format(lib_key=lib_key), data, expect_response)
|
||||
|
||||
def _delete_library(self, lib_key, expect_response=200):
|
||||
""" Delete an existing library """
|
||||
return self._api('delete', URL_LIB_DETAIL.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _commit_library_changes(self, lib_key):
|
||||
def _commit_library_changes(self, lib_key, expect_response=200):
|
||||
""" Commit changes to an existing library """
|
||||
return self._api('post', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response=200)
|
||||
return self._api('post', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _revert_library_changes(self, lib_key):
|
||||
def _revert_library_changes(self, lib_key, expect_response=200):
|
||||
""" Revert pending changes to an existing library """
|
||||
return self._api('delete', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response=200)
|
||||
return self._api('delete', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _get_library_blocks(self, lib_key):
|
||||
def _get_library_team(self, lib_key, expect_response=200):
|
||||
""" Get the list of users/groups authorized to use this library """
|
||||
return self._api('get', URL_LIB_TEAM.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _set_user_access_level(self, lib_key, user_id, access_level, expect_response=200):
|
||||
""" Change the specified user's access level """
|
||||
url = URL_LIB_TEAM_USER.format(lib_key=lib_key, user_id=user_id)
|
||||
if access_level is None:
|
||||
return self._api('delete', url, None, expect_response)
|
||||
else:
|
||||
return self._api('put', url, {"access_level": access_level}, expect_response)
|
||||
|
||||
def _set_group_access_level(self, lib_key, group_name, access_level, expect_response=200):
|
||||
""" Change the specified group's access level """
|
||||
url = URL_LIB_TEAM_GROUP.format(lib_key=lib_key, group_name=group_name)
|
||||
if access_level is None:
|
||||
return self._api('delete', url, None, expect_response)
|
||||
else:
|
||||
return self._api('put', url, {"access_level": access_level}, expect_response)
|
||||
|
||||
def _get_library_blocks(self, lib_key, expect_response=200):
|
||||
""" Get the list of XBlocks in the library """
|
||||
return self._api('get', URL_LIB_BLOCKS.format(lib_key=lib_key), None, expect_response=200)
|
||||
return self._api('get', URL_LIB_BLOCKS.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200):
|
||||
""" Add a new XBlock to the library """
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
"""
|
||||
Tests for Blockstore-based Content Libraries
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
class ContentLibrariesTest(ContentLibrariesRestApiTest):
|
||||
@@ -49,18 +51,18 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
|
||||
"has_unpublished_changes": False,
|
||||
"has_unpublished_deletes": False,
|
||||
}
|
||||
self.assertDictContainsSubset(expected_data, lib)
|
||||
self.assertDictContainsEntries(lib, expected_data)
|
||||
# Check that bundle_uuid looks like a valid UUID
|
||||
UUID(lib["bundle_uuid"]) # will raise an exception if not valid
|
||||
|
||||
# Read:
|
||||
lib2 = self._get_library(lib["id"])
|
||||
self.assertDictContainsSubset(expected_data, lib2)
|
||||
self.assertDictContainsEntries(lib2, expected_data)
|
||||
|
||||
# Update:
|
||||
lib3 = self._update_library(lib["id"], title="New Title")
|
||||
expected_data["title"] = "New Title"
|
||||
self.assertDictContainsSubset(expected_data, lib3)
|
||||
self.assertDictContainsEntries(lib3, expected_data)
|
||||
|
||||
# Delete:
|
||||
self._delete_library(lib["id"])
|
||||
@@ -94,12 +96,12 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
|
||||
|
||||
# Add a 'problem' XBlock to the library:
|
||||
block_data = self._add_block_to_library(lib_id, "problem", "problem1")
|
||||
self.assertDictContainsSubset({
|
||||
self.assertDictContainsEntries(block_data, {
|
||||
"id": "lb:CL-TEST:testlib1:problem:problem1",
|
||||
"display_name": "Blank Advanced Problem",
|
||||
"block_type": "problem",
|
||||
"has_unpublished_changes": True,
|
||||
}, block_data)
|
||||
})
|
||||
block_id = block_data["id"]
|
||||
# Confirm that the result contains a definition key, but don't check its value,
|
||||
# which for the purposes of these tests is an implementation detail.
|
||||
@@ -114,7 +116,7 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
|
||||
self.assertEqual(self._get_library(lib_id)["has_unpublished_changes"], False)
|
||||
# And now the block information should also show that block has no unpublished changes:
|
||||
block_data["has_unpublished_changes"] = False
|
||||
self.assertDictContainsSubset(block_data, self._get_library_block(block_id))
|
||||
self.assertDictContainsEntries(self._get_library_block(block_id), block_data)
|
||||
self.assertEqual(self._get_library_blocks(lib_id), [block_data])
|
||||
|
||||
# Now update the block's OLX:
|
||||
@@ -138,10 +140,10 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
|
||||
# now reading it back, we should get that exact OLX (no change to whitespace etc.):
|
||||
self.assertEqual(self._get_library_block_olx(block_id), new_olx)
|
||||
# And the display name and "unpublished changes" status of the block should be updated:
|
||||
self.assertDictContainsSubset({
|
||||
self.assertDictContainsEntries(self._get_library_block(block_id), {
|
||||
"display_name": "New Multi Choice Question",
|
||||
"has_unpublished_changes": True,
|
||||
}, self._get_library_block(block_id))
|
||||
})
|
||||
|
||||
# Now view the XBlock's student_view (including draft changes):
|
||||
fragment = self._render_block_view(block_id, "student_view")
|
||||
@@ -209,3 +211,147 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
|
||||
# We cannot add a duplicate ID to the library, either at the top level or as a child:
|
||||
self._add_block_to_library(lib_id, "problem", "problem1", expect_response=400)
|
||||
self._add_block_to_library(lib_id, "problem", "problem1", parent_block=unit_block["id"], expect_response=400)
|
||||
|
||||
# Test that permissions are enforced for content libraries
|
||||
|
||||
def test_library_permissions(self): # pylint: disable=too-many-statements
|
||||
"""
|
||||
Test that permissions are enforced for content libraries, and that
|
||||
permissions can be read and manipulated using the REST API (which in
|
||||
turn tests the python API).
|
||||
|
||||
This is a single giant test case, because that optimizes for the fastest
|
||||
test run time, even though it can make debugging failures harder.
|
||||
"""
|
||||
# Create a few users to use for all of these tests:
|
||||
admin = UserFactory.create(username="Admin", email="admin@example.com")
|
||||
author = UserFactory.create(username="Author", email="author@example.com")
|
||||
reader = UserFactory.create(username="Reader", email="reader@example.com")
|
||||
group = Group.objects.create(name="group1")
|
||||
author_group_member = UserFactory.create(username="GroupMember", email="groupmember@example.com")
|
||||
author_group_member.groups.add(group)
|
||||
random_user = UserFactory.create(username="Random", email="random@example.com")
|
||||
|
||||
# Library CRUD #########################################################
|
||||
|
||||
# Create a library, owned by "Admin"
|
||||
with self.as_user(admin):
|
||||
lib = self._create_library(slug="permtest", title="Permission Test Library", description="Testing")
|
||||
lib_id = lib["id"]
|
||||
# By default, "public learning" and public read access are disallowed.
|
||||
self.assertEqual(lib["allow_public_learning"], False)
|
||||
self.assertEqual(lib["allow_public_read"], False)
|
||||
|
||||
# By default, the creator of a new library is the only admin
|
||||
data = self._get_library_team(lib_id)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertDictContainsEntries(data[0], {"user_id": admin.pk, "group_name": None, "access_level": "admin"})
|
||||
|
||||
# Add the other users to the content library:
|
||||
self._set_user_access_level(lib_id, author.pk, access_level="author")
|
||||
self._set_user_access_level(lib_id, reader.pk, access_level="read")
|
||||
self._set_group_access_level(lib_id, group.name, access_level="author")
|
||||
|
||||
team_response = self._get_library_team(lib_id)
|
||||
self.assertEqual(len(team_response), 4)
|
||||
# The response should also always be sorted in a specific order (by username and group name):
|
||||
expected_response = [
|
||||
{"user_id": None, "group_name": "group1", "access_level": "author"},
|
||||
{"user_id": admin.pk, "group_name": None, "access_level": "admin"},
|
||||
{"user_id": author.pk, "group_name": None, "access_level": "author"},
|
||||
{"user_id": reader.pk, "group_name": None, "access_level": "read"},
|
||||
]
|
||||
for entry, expected in zip(team_response, expected_response):
|
||||
self.assertDictContainsEntries(entry, expected)
|
||||
|
||||
# A random user cannot get the library nor its team:
|
||||
with self.as_user(random_user):
|
||||
self._get_library(lib_id, expect_response=403)
|
||||
self._get_library_team(lib_id, expect_response=403)
|
||||
|
||||
# But every authorized user can:
|
||||
for user in [admin, author, author_group_member]:
|
||||
with self.as_user(user):
|
||||
self._get_library(lib_id)
|
||||
data = self._get_library_team(lib_id)
|
||||
self.assertEqual(data, team_response)
|
||||
|
||||
# A user with only read permission can get data about the library but not the team:
|
||||
with self.as_user(reader):
|
||||
self._get_library(lib_id)
|
||||
self._get_library_team(lib_id, expect_response=403)
|
||||
|
||||
# Users without admin access cannot delete the library nor change its team:
|
||||
for user in [author, reader, author_group_member, random_user]:
|
||||
with self.as_user(user):
|
||||
self._delete_library(lib_id, expect_response=403)
|
||||
self._set_user_access_level(lib_id, author.pk, access_level="admin", expect_response=403)
|
||||
self._set_user_access_level(lib_id, admin.pk, access_level=None, expect_response=403)
|
||||
self._set_user_access_level(lib_id, random_user.pk, access_level="read", expect_response=403)
|
||||
|
||||
# Users with author access (or higher) can edit the library's properties:
|
||||
with self.as_user(author):
|
||||
self._update_library(lib_id, description="Revised description")
|
||||
with self.as_user(author_group_member):
|
||||
self._update_library(lib_id, title="New Library Title")
|
||||
# But other users cannot:
|
||||
with self.as_user(reader):
|
||||
self._update_library(lib_id, description="Prohibited description", expect_response=403)
|
||||
with self.as_user(random_user):
|
||||
self._update_library(lib_id, title="I can't set this title", expect_response=403)
|
||||
# Verify the permitted changes were made:
|
||||
with self.as_user(admin):
|
||||
data = self._get_library(lib_id)
|
||||
self.assertEqual(data["description"], "Revised description")
|
||||
self.assertEqual(data["title"], "New Library Title")
|
||||
|
||||
# Library XBlock editing ###############################################
|
||||
|
||||
# users with read permission or less cannot add blocks:
|
||||
for user in [reader, random_user]:
|
||||
with self.as_user(user):
|
||||
self._add_block_to_library(lib_id, "problem", "problem1", expect_response=403)
|
||||
# But authors and admins can:
|
||||
with self.as_user(admin):
|
||||
self._add_block_to_library(lib_id, "problem", "problem1")
|
||||
with self.as_user(author):
|
||||
self._add_block_to_library(lib_id, "problem", "problem2")
|
||||
with self.as_user(author_group_member):
|
||||
block3_data = self._add_block_to_library(lib_id, "problem", "problem3")
|
||||
block3_key = block3_data["id"]
|
||||
|
||||
# At this point, the library contains 3 draft problem XBlocks.
|
||||
|
||||
# A random user cannot read OLX nor assets (this library has allow_public_read False):
|
||||
with self.as_user(random_user):
|
||||
self._get_library_block_olx(block3_key, expect_response=403)
|
||||
self._get_library_block_assets(block3_key, expect_response=403)
|
||||
self._get_library_block_asset(block3_key, file_name="whatever.png", expect_response=403)
|
||||
# But if we grant allow_public_read, then they can:
|
||||
with self.as_user(admin):
|
||||
self._update_library(lib_id, allow_public_read=True)
|
||||
self._set_library_block_asset(block3_key, "whatever.png", b"data")
|
||||
with self.as_user(random_user):
|
||||
self._get_library_block_olx(block3_key)
|
||||
self._get_library_block_assets(block3_key)
|
||||
self._get_library_block_asset(block3_key, file_name="whatever.png")
|
||||
|
||||
# Users without authoring permission cannot edit nor delete XBlocks (this library has allow_public_read False):
|
||||
for user in [reader, random_user]:
|
||||
with self.as_user(user):
|
||||
self._set_library_block_olx(block3_key, "<problem/>", expect_response=403)
|
||||
self._set_library_block_asset(block3_key, "test.txt", b"data", expect_response=403)
|
||||
self._delete_library_block(block3_key, expect_response=403)
|
||||
self._commit_library_changes(lib_id, expect_response=403)
|
||||
self._revert_library_changes(lib_id, expect_response=403)
|
||||
|
||||
# But users with author permission can:
|
||||
with self.as_user(author_group_member):
|
||||
olx = self._get_library_block_olx(block3_key)
|
||||
self._set_library_block_olx(block3_key, olx)
|
||||
self._get_library_block_assets(block3_key)
|
||||
self._set_library_block_asset(block3_key, "test.txt", b"data")
|
||||
self._get_library_block_asset(block3_key, file_name="test.txt")
|
||||
self._delete_library_block(block3_key)
|
||||
self._commit_library_changes(lib_id)
|
||||
self._revert_library_changes(lib_id) # This is a no-op after the commit, but should still have 200 response
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"""
|
||||
Test the Blockstore-based XBlock runtime and content libraries together.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
@@ -50,6 +49,8 @@ class ContentLibraryContentTestMixin(object):
|
||||
slug=cls.__name__,
|
||||
title=(cls.__name__ + " Test Lib"),
|
||||
description="",
|
||||
allow_public_learning=True,
|
||||
allow_public_read=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,6 +82,8 @@ class ContentLibraryRuntimeTest(ContentLibraryContentTestMixin, TestCase):
|
||||
slug="idolx",
|
||||
title=("Identical OLX Test Lib 2"),
|
||||
description="",
|
||||
allow_public_learning=True,
|
||||
allow_public_read=False,
|
||||
)
|
||||
unit_block2_key = library_api.create_library_block(library2.key, "unit", "u1").usage_key
|
||||
library_api.create_library_block_child(unit_block2_key, "problem", "p1")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
URL configuration for Studio's Content Libraries REST API
|
||||
"""
|
||||
|
||||
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from . import views
|
||||
@@ -26,6 +24,12 @@ urlpatterns = [
|
||||
url(r'^blocks/$', views.LibraryBlocksView.as_view()),
|
||||
# Commit (POST) or revert (DELETE) all pending changes to this library:
|
||||
url(r'^commit/$', views.LibraryCommitView.as_view()),
|
||||
# Get the list of users/groups who have permission to view/edit/administer this library:
|
||||
url(r'^team/$', views.LibraryTeamView.as_view()),
|
||||
# Add/Edit (PUT) or remove (DELETE) a user's permission to use this library
|
||||
url(r'^team/user/(?P<user_id>\d+)/$', views.LibraryTeamUserView.as_view()),
|
||||
# Add/Edit (PUT) or remove (DELETE) a group's permission to use this library
|
||||
url(r'^team/group/(?P<group_name>[^/]+)/$', views.LibraryTeamGroupView.as_view()),
|
||||
])),
|
||||
url(r'^blocks/(?P<usage_key_str>[^/]+)/', include([
|
||||
# Get metadata about a specific XBlock in this library, or delete the block:
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"""
|
||||
REST API for Blockstore-based content libraries
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.shortcuts import get_object_or_404
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from organizations.models import Organization
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from . import api
|
||||
from .serializers import (
|
||||
from openedx.core.djangoapps.content_libraries import api, permissions
|
||||
from openedx.core.djangoapps.content_libraries.serializers import (
|
||||
ContentLibraryMetadataSerializer,
|
||||
ContentLibraryUpdateSerializer,
|
||||
ContentLibraryPermissionLevelSerializer,
|
||||
ContentLibraryPermissionSerializer,
|
||||
LibraryXBlockCreationSerializer,
|
||||
LibraryXBlockMetadataSerializer,
|
||||
LibraryXBlockTypeSerializer,
|
||||
@@ -25,7 +28,9 @@ from .serializers import (
|
||||
LibraryXBlockStaticFileSerializer,
|
||||
LibraryXBlockStaticFilesSerializer,
|
||||
)
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
|
||||
User = get_user_model()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -62,16 +67,19 @@ class LibraryRootView(APIView):
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
Return a list of all content libraries. This is a temporary view for
|
||||
development.
|
||||
Return a list of all content libraries that the user has permission to
|
||||
view. This is a temporary view for development and returns at most 50
|
||||
libraries.
|
||||
"""
|
||||
result = api.list_libraries()
|
||||
result = api.list_libraries_for_user(request.user)
|
||||
return Response(ContentLibraryMetadataSerializer(result, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Create a new content library.
|
||||
"""
|
||||
if not request.user.has_perm(permissions.CAN_CREATE_CONTENT_LIBRARY):
|
||||
raise PermissionDenied
|
||||
serializer = ContentLibraryMetadataSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
@@ -103,6 +111,7 @@ class LibraryDetailsView(APIView):
|
||||
Get a specific content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
result = api.get_library(key)
|
||||
return Response(ContentLibraryMetadataSerializer(result).data)
|
||||
|
||||
@@ -112,6 +121,7 @@ class LibraryDetailsView(APIView):
|
||||
Update a content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
serializer = ContentLibraryUpdateSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
api.update_library(key, **serializer.validated_data)
|
||||
@@ -124,10 +134,97 @@ class LibraryDetailsView(APIView):
|
||||
Delete a content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_DELETE_THIS_CONTENT_LIBRARY)
|
||||
api.delete_library(key)
|
||||
return Response({})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryTeamView(APIView):
|
||||
"""
|
||||
View to get the list of users/groups who can access and edit the content
|
||||
library.
|
||||
|
||||
Note also the 'allow_public_' settings which can be edited by PATCHing the
|
||||
library itself (LibraryDetailsView.patch).
|
||||
"""
|
||||
@convert_exceptions
|
||||
def get(self, request, lib_key_str):
|
||||
"""
|
||||
Get the list of users and groups who have permissions to view and edit
|
||||
this library.
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM)
|
||||
team = api.get_library_team(key)
|
||||
return Response(ContentLibraryPermissionSerializer(team, many=True).data)
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryTeamUserView(APIView):
|
||||
"""
|
||||
View to add/remove/edit an individual user's permissions for a content
|
||||
library.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def put(self, request, lib_key_str, user_id):
|
||||
"""
|
||||
Add a user to this content library, with permissions specified in the
|
||||
request body.
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
|
||||
serializer = ContentLibraryPermissionLevelSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = get_object_or_404(User, pk=int(user_id))
|
||||
api.set_library_user_permissions(key, user, access_level=serializer.validated_data["access_level"])
|
||||
return Response({})
|
||||
|
||||
@convert_exceptions
|
||||
def delete(self, request, lib_key_str, user_id):
|
||||
"""
|
||||
Remove the specified user's permission to access or edit this content
|
||||
library.
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
|
||||
user = get_object_or_404(User, pk=int(user_id))
|
||||
api.set_library_user_permissions(key, user, access_level=None)
|
||||
return Response({})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryTeamGroupView(APIView):
|
||||
"""
|
||||
View to add/remove/edit a group's permissions for a content library.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def put(self, request, lib_key_str, group_name):
|
||||
"""
|
||||
Add a group to this content library, with permissions specified in the
|
||||
request body.
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
|
||||
serializer = ContentLibraryPermissionLevelSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
group = get_object_or_404(Group, name=group_name)
|
||||
api.set_library_group_permissions(key, group, access_level=serializer.validated_data["access_level"])
|
||||
return Response({})
|
||||
|
||||
@convert_exceptions
|
||||
def delete(self, request, lib_key_str, user_id):
|
||||
"""
|
||||
Remove the specified user's permission to access or edit this content
|
||||
library.
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
|
||||
group = get_object_or_404(Group, pk=int(user_id))
|
||||
api.set_library_group_permissions(key, group, access_level=None)
|
||||
return Response({})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryBlockTypesView(APIView):
|
||||
"""
|
||||
@@ -139,6 +236,7 @@ class LibraryBlockTypesView(APIView):
|
||||
Get the list of XBlock types that can be added to this library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
result = api.get_allowed_block_types(key)
|
||||
return Response(LibraryXBlockTypeSerializer(result, many=True).data)
|
||||
|
||||
@@ -155,6 +253,7 @@ class LibraryCommitView(APIView):
|
||||
descendants.
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
api.publish_changes(key)
|
||||
return Response({})
|
||||
|
||||
@@ -165,6 +264,7 @@ class LibraryCommitView(APIView):
|
||||
descendants. Restore it to the last published version
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
api.revert_changes(key)
|
||||
return Response({})
|
||||
|
||||
@@ -180,6 +280,7 @@ class LibraryBlocksView(APIView):
|
||||
Get the list of all top-level blocks in this content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
result = api.get_library_blocks(key)
|
||||
return Response(LibraryXBlockMetadataSerializer(result, many=True).data)
|
||||
|
||||
@@ -189,6 +290,7 @@ class LibraryBlocksView(APIView):
|
||||
Add a new XBlock to this content library
|
||||
"""
|
||||
library_key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
serializer = LibraryXBlockCreationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
parent_block_usage_str = serializer.validated_data.pop("parent_block", None)
|
||||
@@ -215,6 +317,7 @@ class LibraryBlockView(APIView):
|
||||
Get metadata about an existing XBlock in the content library
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
result = api.get_library_block(key)
|
||||
return Response(LibraryXBlockMetadataSerializer(result).data)
|
||||
|
||||
@@ -232,6 +335,7 @@ class LibraryBlockView(APIView):
|
||||
be deleted but the link and the linked bundle will be unaffected.
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
api.delete_library_block(key)
|
||||
return Response({})
|
||||
|
||||
@@ -247,6 +351,7 @@ class LibraryBlockOlxView(APIView):
|
||||
Get the block's OLX
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
xml_str = api.get_library_block_olx(key)
|
||||
return Response(LibraryXBlockOlxSerializer({"olx": xml_str}).data)
|
||||
|
||||
@@ -259,6 +364,7 @@ class LibraryBlockOlxView(APIView):
|
||||
Very little validation is done.
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
serializer = LibraryXBlockOlxSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
new_olx_str = serializer.validated_data["olx"]
|
||||
@@ -280,6 +386,7 @@ class LibraryBlockAssetListView(APIView):
|
||||
List the static asset files belonging to this block.
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
files = api.get_library_block_static_asset_files(key)
|
||||
return Response(LibraryXBlockStaticFilesSerializer({"files": files}).data)
|
||||
|
||||
@@ -297,6 +404,7 @@ class LibraryBlockAssetView(APIView):
|
||||
Get a static asset file belonging to this block.
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
files = api.get_library_block_static_asset_files(key)
|
||||
for f in files:
|
||||
if f.path == file_path:
|
||||
@@ -309,6 +417,9 @@ class LibraryBlockAssetView(APIView):
|
||||
Replace a static asset file belonging to this block.
|
||||
"""
|
||||
usage_key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(
|
||||
usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
|
||||
)
|
||||
file_wrapper = request.data['content']
|
||||
if file_wrapper.size > 20 * 1024 * 1024: # > 20 MiB
|
||||
# In the future, we need a way to use file_wrapper.chunks() to read
|
||||
@@ -323,11 +434,14 @@ class LibraryBlockAssetView(APIView):
|
||||
return Response(LibraryXBlockStaticFileSerializer(result).data)
|
||||
|
||||
@convert_exceptions
|
||||
def delete(self, request, usage_key_str, file_path): # pylint: disable=unused-argument
|
||||
def delete(self, request, usage_key_str, file_path):
|
||||
"""
|
||||
Delete a static asset file belonging to this block.
|
||||
"""
|
||||
usage_key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(
|
||||
usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
|
||||
)
|
||||
try:
|
||||
api.delete_library_block_static_asset_file(usage_key, file_path)
|
||||
except ValueError:
|
||||
|
||||
@@ -62,14 +62,9 @@ def load_block(usage_key, user):
|
||||
# Is this block part of a course, a library, or what?
|
||||
# Get the Learning Context Implementation based on the usage key
|
||||
context_impl = get_learning_context_impl(usage_key)
|
||||
# Now, using the LearningContext and the Studio/LMS-specific logic, check if
|
||||
# the block exists in this context and if the user has permission to render
|
||||
# this XBlock view:
|
||||
if get_xblock_app_config().require_edit_permission:
|
||||
authorized = context_impl.can_edit_block(user, usage_key)
|
||||
else:
|
||||
authorized = context_impl.can_view_block(user, usage_key)
|
||||
if not authorized:
|
||||
# Now, check if the block exists in this context and if the user has
|
||||
# permission to render this XBlock view:
|
||||
if user is not None and not context_impl.can_view_block(user, usage_key):
|
||||
# We do not know if the block was not found or if the user doesn't have
|
||||
# permission, but we want to return the same result in either case:
|
||||
raise NotFound("XBlock {} does not exist, or you don't have permission to view it.".format(usage_key))
|
||||
|
||||
@@ -17,10 +17,6 @@ class XBlockAppConfig(AppConfig):
|
||||
verbose_name = 'New XBlock Runtime'
|
||||
label = 'xblock_new' # The name 'xblock' is already taken by ORA2's 'openassessment.xblock' app :/
|
||||
|
||||
# If this is True, users must have 'edit' permission to be allowed even to
|
||||
# view content. (It's only true in Studio)
|
||||
require_edit_permission = False
|
||||
|
||||
def get_runtime_system_params(self):
|
||||
"""
|
||||
Get the XBlockRuntimeSystem parameters appropriate for viewing and/or
|
||||
@@ -72,8 +68,6 @@ class StudioXBlockAppConfig(XBlockAppConfig):
|
||||
"""
|
||||
Studio-specific configuration of the XBlock Runtime django app.
|
||||
"""
|
||||
# In Studio, users must have 'edit' permission to be allowed even to view content
|
||||
require_edit_permission = True
|
||||
|
||||
BLOCKSTORE_DRAFT_NAME = "studio_draft"
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ class LearningContext(object):
|
||||
def can_edit_block(self, user, usage_key): # pylint: disable=unused-argument
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
specified user have permission to edit it?
|
||||
specified user have permission to edit it (make changes to the authored
|
||||
data store)?
|
||||
|
||||
user: a Django User object (may be an AnonymousUser)
|
||||
|
||||
|
||||
@@ -120,6 +120,13 @@ class BlockstoreXBlockRuntime(XBlockRuntime):
|
||||
"The Blockstore runtime does not support saving changes to blockstore without a draft. "
|
||||
"Are you making changes to UserScope.NONE fields from the LMS rather than Studio?"
|
||||
)
|
||||
# Verify that the user has permission to write to authored data in this
|
||||
# learning context:
|
||||
if self.user is not None:
|
||||
learning_context = get_learning_context_impl(block.scope_ids.usage_id)
|
||||
if not learning_context.can_edit_block(self.user, block.scope_ids.usage_id):
|
||||
log.warning("User %s does not have permission to edit %s", self.user.username, block.scope_ids.usage_id)
|
||||
raise RuntimeError("You do not have permission to edit this XBlock")
|
||||
olx_str, static_files = serialize_xblock(block)
|
||||
# Write the OLX file to the bundle:
|
||||
draft_uuid = blockstore_api.get_or_create_bundle_draft(
|
||||
|
||||
Reference in New Issue
Block a user