fix: Improve v2 library block permissions checks for read-only authors (#35598)
This commit is contained in:
@@ -156,7 +156,7 @@
|
||||
<!-- The default stylesheet will set the body min-height to 100% (a common strategy to allow for background
|
||||
images to fill the viewport), but this has the undesireable side-effect of causing an infinite loop via the onResize
|
||||
event listeners below, in certain situations. Resetting it to the default "auto" skirts the problem.-->
|
||||
<body style="min-height: auto">
|
||||
<body style="min-height: auto; background-color: white;">
|
||||
<!-- fragment body -->
|
||||
{{ fragment.body_html | safe }}
|
||||
<!-- fragment foot -->
|
||||
@@ -1 +0,0 @@
|
||||
../../../cms/templates/content_libraries/xblock_iframe.html
|
||||
@@ -1,19 +1,21 @@
|
||||
"""
|
||||
Definition of "Library" as a learning context.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
from openedx_events.content_authoring.data import LibraryBlockData
|
||||
from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED
|
||||
from opaque_keys.edx.keys import UsageKeyV2
|
||||
from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
|
||||
from openedx.core.djangoapps.content_libraries import api, permissions
|
||||
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
|
||||
from openedx.core.djangoapps.xblock.api import LearningContext
|
||||
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx.core.types import User as UserType
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,47 +32,51 @@ class LibraryContextImpl(LearningContext):
|
||||
super().__init__(**kwargs)
|
||||
self.use_draft = kwargs.get('use_draft', None)
|
||||
|
||||
def can_edit_block(self, user, usage_key):
|
||||
def can_edit_block(self, user: UserType, usage_key: UsageKeyV2) -> bool:
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
specified user have permission to edit it (make changes to the authored
|
||||
data store)?
|
||||
Assuming a block with the specified ID (usage_key) exists, does the
|
||||
specified user have permission to edit it (make changes to the
|
||||
fields / 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.
|
||||
May raise ContentLibraryNotFound if the library does not exist.
|
||||
"""
|
||||
try:
|
||||
api.require_permission_for_library_key(usage_key.lib_key, user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
except (PermissionDenied, api.ContentLibraryNotFound):
|
||||
return False
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
return self._check_perm(user, usage_key.lib_key, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
|
||||
return self.block_exists(usage_key)
|
||||
def can_view_block_for_editing(self, user: UserType, usage_key: UsageKeyV2) -> bool:
|
||||
"""
|
||||
Assuming a block with the specified ID (usage_key) exists, does the
|
||||
specified user have permission to view its fields and OLX details (but
|
||||
not necessarily to make changes to it)?
|
||||
|
||||
def can_view_block(self, user, usage_key):
|
||||
May raise ContentLibraryNotFound if the library does not exist.
|
||||
"""
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
return self._check_perm(user, usage_key.lib_key, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
|
||||
def can_view_block(self, user: UserType, usage_key: UsageKeyV2) -> bool:
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
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.
|
||||
May raise ContentLibraryNotFound if the library does not exist.
|
||||
"""
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
return self._check_perm(user, usage_key.lib_key, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY)
|
||||
|
||||
def _check_perm(self, user: UserType, lib_key: LibraryLocatorV2, perm) -> bool:
|
||||
""" Helper method to check a permission for the various can_ methods"""
|
||||
try:
|
||||
api.require_permission_for_library_key(
|
||||
usage_key.lib_key, user, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY,
|
||||
)
|
||||
except (PermissionDenied, api.ContentLibraryNotFound):
|
||||
api.require_permission_for_library_key(lib_key, user, perm)
|
||||
return True
|
||||
except PermissionDenied:
|
||||
return False
|
||||
except api.ContentLibraryNotFound as exc:
|
||||
# A 404 is probably what you want in this case, not a 500 error, so do that by default.
|
||||
raise NotFound(f"Content Library '{lib_key}' does not exist") from exc
|
||||
|
||||
return self.block_exists(usage_key)
|
||||
|
||||
def block_exists(self, usage_key):
|
||||
def block_exists(self, usage_key: LibraryUsageLocatorV2):
|
||||
"""
|
||||
Does the block for this usage_key exist in this Library?
|
||||
|
||||
@@ -82,7 +88,7 @@ class LibraryContextImpl(LearningContext):
|
||||
version of it.
|
||||
"""
|
||||
try:
|
||||
content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key)
|
||||
content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key) # type: ignore[attr-defined]
|
||||
except ContentLibrary.DoesNotExist:
|
||||
return False
|
||||
|
||||
@@ -97,12 +103,11 @@ class LibraryContextImpl(LearningContext):
|
||||
local_key=usage_key.block_id,
|
||||
)
|
||||
|
||||
def send_block_updated_event(self, usage_key):
|
||||
def send_block_updated_event(self, usage_key: UsageKeyV2):
|
||||
"""
|
||||
Send a "block updated" event for the library block with the given usage_key.
|
||||
|
||||
usage_key: the UsageKeyV2 subclass used for this learning context
|
||||
"""
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
LIBRARY_BLOCK_UPDATED.send_event(
|
||||
library_block=LibraryBlockData(
|
||||
library_key=usage_key.lib_key,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
Permissions for Content Libraries (v2, Learning-Core-based)
|
||||
"""
|
||||
from bridgekeeper import perms, rules
|
||||
from bridgekeeper.rules import Attribute, ManyRelation, Relation, in_current_groups
|
||||
from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups
|
||||
from django.conf import settings
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
|
||||
|
||||
@@ -41,6 +42,12 @@ has_explicit_admin_permission_for_library = (
|
||||
)
|
||||
|
||||
|
||||
# Are we in Studio? (Is there a better or more contextual way to define this, e.g. get from learning context?)
|
||||
@blanket_rule
|
||||
def is_studio_request(_):
|
||||
return settings.SERVICE_VARIANT == "cms"
|
||||
|
||||
|
||||
########################### Permissions ###########################
|
||||
|
||||
# Is the user allowed to view XBlocks from the specified content library
|
||||
@@ -51,10 +58,12 @@ 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:
|
||||
# Regular and even anonymous 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:
|
||||
(is_user_active & has_explicit_read_permission_for_library)
|
||||
(is_user_active & has_explicit_read_permission_for_library) |
|
||||
# Or, in Studio (but not the LMS) any users can access libraries with "public read" permissions:
|
||||
(is_studio_request & is_user_active & Attribute('allow_public_read', True))
|
||||
)
|
||||
|
||||
# Is the user allowed to create content libraries?
|
||||
|
||||
@@ -308,3 +308,12 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
|
||||
"""
|
||||
url = URL_BLOCK_GET_HANDLER_URL.format(block_key=block_key, handler_name=handler_name)
|
||||
return self._api('get', url, None, expect_response=200)["handler_url"]
|
||||
|
||||
def _get_library_block_fields(self, block_key, expect_response=200):
|
||||
""" Get the fields of a specific block in the library. This API is only used by the MFE editors. """
|
||||
result = self._api('get', URL_BLOCK_FIELDS_URL.format(block_key=block_key), None, expect_response)
|
||||
return result
|
||||
|
||||
def _set_library_block_fields(self, block_key, new_fields, expect_response=200):
|
||||
""" Set the fields of a specific block in the library. This API is only used by the MFE editors. """
|
||||
return self._api('post', URL_BLOCK_FIELDS_URL.format(block_key=block_key), new_fields, expect_response)
|
||||
|
||||
@@ -502,6 +502,30 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
|
||||
# Add a 'problem' XBlock to the library:
|
||||
self._add_block_to_library(lib_id, block_type, 'test-block', expect_response=expect_response)
|
||||
|
||||
def test_library_not_found(self):
|
||||
"""Test that requests fail with 404 when the library does not exist"""
|
||||
valid_not_found_key = 'lb:valid:key:video:1'
|
||||
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json(), {
|
||||
'detail': "Content Library 'lib:valid:key' does not exist",
|
||||
})
|
||||
|
||||
def test_block_not_found(self):
|
||||
"""Test that requests fail with 404 when the library exists but the XBlock does not"""
|
||||
lib = self._create_library(
|
||||
slug="test_lib_block_event_delete",
|
||||
title="Event Test Library",
|
||||
description="Testing event in library"
|
||||
)
|
||||
library_key = LibraryLocatorV2.from_string(lib['id'])
|
||||
non_existent_block_key = LibraryUsageLocatorV2(lib_key=library_key, block_type='video', usage_id='123')
|
||||
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=non_existent_block_key))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json(), {
|
||||
'detail': f"The component '{non_existent_block_key}' does not exist.",
|
||||
})
|
||||
|
||||
# Test that permissions are enforced for content libraries
|
||||
|
||||
def test_library_permissions(self): # pylint: disable=too-many-statements
|
||||
@@ -635,21 +659,27 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
|
||||
# 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_fields(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)
|
||||
# Nor can they preview the block:
|
||||
self._render_block_view(block3_key, view_name="student_view", 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._render_block_view(block3_key, view_name="student_view")
|
||||
f = self._get_library_block_fields(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):
|
||||
# Users without authoring permission cannot edit nor delete XBlocks:
|
||||
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_fields(block3_key, {"data": "<problem />", "metadata": {}}, 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)
|
||||
@@ -659,6 +689,7 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
|
||||
with self.as_user(author_group_member):
|
||||
olx = self._get_library_block_olx(block3_key)
|
||||
self._set_library_block_olx(block3_key, olx)
|
||||
self._set_library_block_fields(block3_key, {"data": olx, "metadata": {}})
|
||||
# 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")
|
||||
@@ -1087,12 +1118,3 @@ class ContentLibraryXBlockValidationTest(APITestCase):
|
||||
secure_token='random',
|
||||
)))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_not_found_fails_correctly(self):
|
||||
"""Test fails with 404 when xblock key is valid but not found."""
|
||||
valid_not_found_key = 'lb:valid:key:video:1'
|
||||
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json(), {
|
||||
'detail': f"XBlock {valid_not_found_key} does not exist, or you don't have permission to view it.",
|
||||
})
|
||||
|
||||
@@ -919,7 +919,7 @@ class LtiToolLaunchView(TemplateResponseMixin, LtiToolView):
|
||||
LTI platform. Other features and resouces are ignored.
|
||||
"""
|
||||
|
||||
template_name = 'content_libraries/xblock_iframe.html'
|
||||
template_name = 'xblock_v2/xblock_iframe.html'
|
||||
|
||||
@property
|
||||
def launch_data(self):
|
||||
|
||||
@@ -8,11 +8,12 @@ Note that these views are only for interacting with existing blocks. Other
|
||||
Studio APIs cover use cases like adding/deleting/editing blocks.
|
||||
"""
|
||||
# pylint: disable=unused-import
|
||||
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
@@ -21,7 +22,7 @@ from opaque_keys.edx.keys import UsageKeyV2
|
||||
from opaque_keys.edx.locator import BundleDefinitionLocator, LibraryUsageLocatorV2
|
||||
from rest_framework.exceptions import NotFound
|
||||
from xblock.core import XBlock
|
||||
from xblock.exceptions import NoSuchViewError
|
||||
from xblock.exceptions import NoSuchUsage, NoSuchViewError
|
||||
from xblock.plugin import PluginMissingError
|
||||
|
||||
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
|
||||
@@ -43,6 +44,16 @@ from openedx.core.djangoapps.xblock.learning_context import LearningContext
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CheckPerm(Enum):
|
||||
""" Options for the default permission check done by load_block() """
|
||||
# can view the published block and call handlers etc. but not necessarily view its OLX source nor field data
|
||||
CAN_LEARN = 1
|
||||
# read-only studio view: can see the block (draft or published), see its OLX, see its field data, etc.
|
||||
CAN_READ_AS_AUTHOR = 2
|
||||
# can view everything and make changes to the block
|
||||
CAN_EDIT = 3
|
||||
|
||||
|
||||
def get_runtime_system():
|
||||
"""
|
||||
Return a new XBlockRuntimeSystem.
|
||||
@@ -74,15 +85,15 @@ def get_runtime_system():
|
||||
return runtime
|
||||
|
||||
|
||||
def load_block(usage_key, user):
|
||||
def load_block(usage_key, user, *, check_permission: CheckPerm | None = CheckPerm.CAN_LEARN):
|
||||
"""
|
||||
Load the specified XBlock for the given user.
|
||||
|
||||
Returns an instantiated XBlock.
|
||||
|
||||
Exceptions:
|
||||
NotFound - if the XBlock doesn't exist or if the user doesn't have the
|
||||
necessary permissions
|
||||
NotFound - if the XBlock doesn't exist
|
||||
PermissionDenied - if the user doesn't have the necessary permissions
|
||||
|
||||
Args:
|
||||
usage_key(OpaqueKey): block identifier
|
||||
@@ -94,10 +105,17 @@ def load_block(usage_key, user):
|
||||
|
||||
# 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(f"XBlock {usage_key} does not exist, or you don't have permission to view it.")
|
||||
if check_permission and user is not None:
|
||||
if check_permission == CheckPerm.CAN_EDIT:
|
||||
has_perm = context_impl.can_edit_block(user, usage_key)
|
||||
elif check_permission == CheckPerm.CAN_READ_AS_AUTHOR:
|
||||
has_perm = context_impl.can_view_block_for_editing(user, usage_key)
|
||||
elif check_permission == CheckPerm.CAN_LEARN:
|
||||
has_perm = context_impl.can_view_block(user, usage_key)
|
||||
else:
|
||||
has_perm = False
|
||||
if not has_perm:
|
||||
raise PermissionDenied(f"You don't have permission to access the component '{usage_key}'.")
|
||||
|
||||
# TODO: load field overrides from the context
|
||||
# e.g. a course might specify that all 'problem' XBlocks have 'max_attempts'
|
||||
@@ -105,7 +123,11 @@ def load_block(usage_key, user):
|
||||
# field_overrides = context_impl.get_field_overrides(usage_key)
|
||||
runtime = get_runtime_system().get_runtime(user=user)
|
||||
|
||||
return runtime.get_block(usage_key)
|
||||
try:
|
||||
return runtime.get_block(usage_key)
|
||||
except NoSuchUsage as exc:
|
||||
# Convert NoSuchUsage to NotFound so we do the right thing (404 not 500) by default.
|
||||
raise NotFound(f"The component '{usage_key}' does not exist.") from exc
|
||||
|
||||
|
||||
def get_block_metadata(block, includes=()):
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
A "Learning Context" is a course, a library, a program, or some other collection
|
||||
of content where learning happens.
|
||||
"""
|
||||
from openedx.core.types import User as UserType
|
||||
from opaque_keys.edx.keys import UsageKeyV2
|
||||
|
||||
|
||||
class LearningContext:
|
||||
@@ -23,11 +25,11 @@ class LearningContext:
|
||||
parameters without changing the API.
|
||||
"""
|
||||
|
||||
def can_edit_block(self, user, usage_key): # pylint: disable=unused-argument
|
||||
def can_edit_block(self, user: UserType, usage_key: UsageKeyV2) -> bool: # 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 (make changes to the authored
|
||||
data store)?
|
||||
Assuming a block with the specified ID (usage_key) exists, does the
|
||||
specified user have permission to edit it (make changes to the
|
||||
fields / authored data store)?
|
||||
|
||||
user: a Django User object (may be an AnonymousUser)
|
||||
|
||||
@@ -37,11 +39,20 @@ class LearningContext:
|
||||
"""
|
||||
return False
|
||||
|
||||
def can_view_block(self, user, usage_key): # pylint: disable=unused-argument
|
||||
def can_view_block_for_editing(self, user: UserType, usage_key: UsageKeyV2) -> bool:
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
Assuming a block with the specified ID (usage_key) exists, does the
|
||||
specified user have permission to view its fields and OLX details (but
|
||||
not necessarily to make changes to it)?
|
||||
"""
|
||||
return self.can_edit_block(user, usage_key)
|
||||
|
||||
def can_view_block(self, user: UserType, usage_key: UsageKeyV2) -> bool: # pylint: disable=unused-argument
|
||||
"""
|
||||
Assuming a block with the specified ID (usage_key) exists, does the
|
||||
specified user have permission to view it and interact with it (call
|
||||
handlers, save user state, etc.)?
|
||||
handlers, save user state, etc.)? This is also sometimes called the
|
||||
"can_learn" permission.
|
||||
|
||||
user: a Django User object (may be an AnonymousUser)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import openedx.core.djangoapps.site_configuration.helpers as configuration_helpe
|
||||
from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from ..api import (
|
||||
CheckPerm,
|
||||
get_block_metadata,
|
||||
get_block_display_name,
|
||||
get_handler_url as _get_handler_url,
|
||||
@@ -108,7 +109,7 @@ def embed_block_view(request, usage_key_str, view_name):
|
||||
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
|
||||
|
||||
try:
|
||||
block = load_block(usage_key, request.user)
|
||||
block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_LEARN)
|
||||
except NoSuchUsage as exc:
|
||||
raise NotFound(f"{usage_key} not found") from exc
|
||||
|
||||
@@ -246,7 +247,8 @@ class BlockFieldsView(APIView):
|
||||
except InvalidKeyError as e:
|
||||
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
|
||||
|
||||
block = load_block(usage_key, request.user)
|
||||
# The "fields" view requires "read as author" permissions because the fields can contain answers, etc.
|
||||
block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_READ_AS_AUTHOR)
|
||||
block_dict = {
|
||||
"display_name": get_block_display_name(block), # potentially duplicated from metadata
|
||||
"data": block.data,
|
||||
@@ -265,7 +267,7 @@ class BlockFieldsView(APIView):
|
||||
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
|
||||
|
||||
user = request.user
|
||||
block = load_block(usage_key, user)
|
||||
block = load_block(usage_key, user, check_permission=CheckPerm.CAN_EDIT)
|
||||
data = request.data.get("data")
|
||||
metadata = request.data.get("metadata")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user