feat: New django app for copying and pasting OLX content in Studio (#31904)
[FC-0009]
This commit is contained in:
2
.github/workflows/pylint-checks.yml
vendored
2
.github/workflows/pylint-checks.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- module-name: lms-2
|
||||
path: "--django-settings-module=lms.envs.test lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_recommendations/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py"
|
||||
- module-name: openedx-1
|
||||
path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
|
||||
path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
|
||||
- module-name: openedx-2
|
||||
path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/learner_pathway/ openedx/core/djangoapps/notifications/"
|
||||
- module-name: common
|
||||
|
||||
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -177,6 +177,7 @@
|
||||
"openedx/core/djangoapps/config_model_utils/",
|
||||
"openedx/core/djangoapps/content/",
|
||||
"openedx/core/djangoapps/content_libraries/",
|
||||
"openedx/core/djangoapps/content_staging/",
|
||||
"openedx/core/djangoapps/contentserver/",
|
||||
"openedx/core/djangoapps/cookie_metadata/",
|
||||
"openedx/core/djangoapps/course_apps/",
|
||||
|
||||
@@ -295,8 +295,43 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView
|
||||
|
||||
copyXBlock: function(event) {
|
||||
event.preventDefault();
|
||||
// This is a new feature, hidden behind a feature flag.
|
||||
alert("Copying of XBlocks is coming soon.");
|
||||
const clipboardEndpoint = "/api/content-staging/v1/clipboard/";
|
||||
const element = this.findXBlockElement(event.target);
|
||||
const usageKeyToCopy = element.data('locator');
|
||||
// Start showing a "Copying" notification:
|
||||
ViewUtils.runOperationShowingMessage(gettext('Copying'), () => {
|
||||
return $.postJSON(
|
||||
clipboardEndpoint,
|
||||
{ usage_key: usageKeyToCopy },
|
||||
).then((data) => {
|
||||
const status = data.content?.status;
|
||||
if (status === "ready") {
|
||||
// The XBlock has been copied and is ready to use.
|
||||
return data;
|
||||
} else if (status === "loading") {
|
||||
// The clipboard is being loaded asynchonously.
|
||||
// Poll the endpoint until the copying process is complete:
|
||||
const deferred = $.Deferred();
|
||||
const checkStatus = () => {
|
||||
$.getJSON(clipboardEndpoint, (pollData) => {
|
||||
const newStatus = pollData.content?.status;
|
||||
if (newStatus === "ready") {
|
||||
deferred.resolve(pollData);
|
||||
} else if (newStatus === "loading") {
|
||||
setTimeout(checkStatus, 1_000);
|
||||
} else {
|
||||
deferred.reject();
|
||||
throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`);
|
||||
}
|
||||
})
|
||||
}
|
||||
setTimeout(checkStatus, 1_000);
|
||||
return deferred;
|
||||
} else {
|
||||
throw new Error(`Unexpected clipboard status "${status}" in successful API response.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
duplicateComponent: function(xblockElement) {
|
||||
|
||||
32
openedx/core/djangoapps/content_staging/README.rst
Normal file
32
openedx/core/djangoapps/content_staging/README.rst
Normal file
@@ -0,0 +1,32 @@
|
||||
=============================
|
||||
Content Staging and Clipboard
|
||||
=============================
|
||||
|
||||
This django app provides APIs to temporarily store content and then retrieve it
|
||||
for use. The content must be detached (not yet part of any course/library) and
|
||||
read-only (this is not a workspace, and the content cannot be edited while
|
||||
staged).
|
||||
|
||||
The primary use case, which is also integrated into this django app, is a
|
||||
per-user clipboard in the CMS (Studio), which can be used to copy and paste
|
||||
components (XBlocks) between courses. At the moment, we only support leaf
|
||||
XBlocks in courses but the goal is to soon support larger pieces of content as
|
||||
well as content libraries.
|
||||
|
||||
As this app is designed only for ephemeral use cases, API consumers should not
|
||||
expect that staged content will be stored for longer than 24 hours.
|
||||
|
||||
This app is part of the CMS and is not intended to work with the LMS. It may be
|
||||
moved from the CMS into the future Learning Core project.
|
||||
|
||||
---------------
|
||||
Clipboard Usage
|
||||
---------------
|
||||
|
||||
* When a user initiates a "Copy" action, the usage key of the component that
|
||||
they wish to copy is POSTed to this app's ``copy`` REST API endpoint.
|
||||
* This app will then use the ``olx_rest_api`` to get the OLX of the item in
|
||||
question, as well as other required data like metadata, static assets, etc.
|
||||
* If the copying action succeeded or is still in progress, a clipboard ID will
|
||||
be returned. There is also an API available to query the "current" clipboard
|
||||
ID of the user. Clipboard are always user-specific and private to a user.
|
||||
0
openedx/core/djangoapps/content_staging/__init__.py
Normal file
0
openedx/core/djangoapps/content_staging/__init__.py
Normal file
34
openedx/core/djangoapps/content_staging/admin.py
Normal file
34
openedx/core/djangoapps/content_staging/admin.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Admin views for Staged Content and Clipboard
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from .models import StagedContent, UserClipboard
|
||||
|
||||
|
||||
@admin.register(StagedContent)
|
||||
class StagedContentAdmin(admin.ModelAdmin):
|
||||
""" Admin config for StagedContent """
|
||||
list_display = ('id', 'user', 'created', 'purpose', 'status', 'block_type', 'display_name', 'suggested_url_name')
|
||||
list_filter = ('purpose', 'status', 'block_type')
|
||||
search_fields = ('user__username', 'display_name', 'suggested_url_name')
|
||||
readonly_fields = ('id', 'user', 'created', 'purpose', 'status', 'block_type', 'olx')
|
||||
|
||||
|
||||
@admin.register(UserClipboard)
|
||||
class UserClipboardAdmin(admin.ModelAdmin):
|
||||
""" Admin config for UserClipboard """
|
||||
list_display = ('user', 'content_link', 'source_usage_key', 'get_source_context_title')
|
||||
search_fields = ('user__username', 'source_usage_key', 'content__display_name')
|
||||
readonly_fields = ('source_context_key', 'get_source_context_title')
|
||||
|
||||
def content_link(self, obj):
|
||||
""" Display the StagedContent object as a link """
|
||||
url = reverse('admin:content_staging_stagedcontent_change', args=[obj.content.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.content)
|
||||
content_link.short_description = 'Content'
|
||||
|
||||
def get_source_context_title(self, obj):
|
||||
return obj.get_source_context_title()
|
||||
get_source_context_title.short_description = 'Source Context Title'
|
||||
4
openedx/core/djangoapps/content_staging/api.py
Normal file
4
openedx/core/djangoapps/content_staging/api.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Public python API for content staging
|
||||
"""
|
||||
# Currently, there is no public API.
|
||||
26
openedx/core/djangoapps/content_staging/apps.py
Normal file
26
openedx/core/djangoapps/content_staging/apps.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
content_staging Django application initialization.
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
from edx_django_utils.plugins import PluginURLs
|
||||
|
||||
from openedx.core.djangoapps.plugins.constants import ProjectType
|
||||
|
||||
|
||||
class ContentStagingAppConfig(AppConfig):
|
||||
"""
|
||||
Configuration for the content_staging Django plugin application.
|
||||
See: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst
|
||||
"""
|
||||
|
||||
name = 'openedx.core.djangoapps.content_staging'
|
||||
verbose_name = 'Content Staging (and clipboard) API'
|
||||
plugin_app = {
|
||||
PluginURLs.CONFIG: {
|
||||
ProjectType.CMS: {
|
||||
PluginURLs.NAMESPACE: '',
|
||||
PluginURLs.REGEX: '',
|
||||
PluginURLs.RELATIVE_PATH: 'urls',
|
||||
},
|
||||
},
|
||||
}
|
||||
99
openedx/core/djangoapps/content_staging/block_serializer.py
Normal file
99
openedx/core/djangoapps/content_staging/block_serializer.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Code for serializing a modulestore XBlock to OLX suitable for import into
|
||||
Blockstore.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from collections import namedtuple
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from openedx.core.djangoapps.olx_rest_api import adapters
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A static file required by an XBlock
|
||||
StaticFile = namedtuple('StaticFile', ['name', 'url', 'data'])
|
||||
|
||||
|
||||
class XBlockSerializer:
|
||||
"""
|
||||
A class that can serializer an XBlock to OLX
|
||||
"""
|
||||
# TEMP: this needs to be consolidated with the XBlockSerializer in olx_rest_api.
|
||||
# i.e. have one base serializer, and a derived blockstore serializer
|
||||
|
||||
def __init__(self, block):
|
||||
"""
|
||||
Serialize an XBlock to an OLX string + supporting files, and store the
|
||||
resulting data in this object.
|
||||
"""
|
||||
self.orig_block_key = block.scope_ids.usage_id
|
||||
self.static_files = []
|
||||
olx_node = self.serialize_block(block)
|
||||
self.olx_str = etree.tostring(olx_node, encoding="unicode", pretty_print=True)
|
||||
|
||||
course_key = self.orig_block_key.course_key
|
||||
# Search the OLX for references to files stored in the course's
|
||||
# "Files & Uploads" (contentstore):
|
||||
self.olx_str = adapters.rewrite_absolute_static_urls(self.olx_str, course_key)
|
||||
for asset in adapters.collect_assets_from_text(self.olx_str, course_key):
|
||||
path = asset['path']
|
||||
if path not in [sf.name for sf in self.static_files]:
|
||||
self.static_files.append(StaticFile(name=path, url=asset['url'], data=None))
|
||||
|
||||
def serialize_block(self, block) -> etree.Element:
|
||||
if self.orig_block_key.block_type == 'html':
|
||||
return self.serialize_html_block(block)
|
||||
else:
|
||||
return self.serialize_normal_block(block)
|
||||
|
||||
def serialize_normal_block(self, block) -> etree.Element:
|
||||
"""
|
||||
Serialize an XBlock to XML.
|
||||
|
||||
This method is used for every block type except HTML, which uses
|
||||
serialize_html_block() instead.
|
||||
"""
|
||||
# Create an XML node to hold the exported data
|
||||
olx_node = etree.Element("root") # The node name doesn't matter: add_xml_to_node will change it
|
||||
# ^ Note: We could pass nsmap=xblock.core.XML_NAMESPACES here, but the
|
||||
# resulting XML namespace attributes don't seem that useful?
|
||||
with adapters.override_export_fs(block) as filesystem: # Needed for XBlocks that inherit XModuleDescriptor
|
||||
# Tell the block to serialize itself as XML/OLX:
|
||||
if not block.has_children:
|
||||
block.add_xml_to_node(olx_node)
|
||||
else:
|
||||
# We don't want the children serialized at this time, because
|
||||
# otherwise we can't tell which files in 'filesystem' belong to
|
||||
# this block and which belong to its children. So, temporarily
|
||||
# disable any children:
|
||||
children = block.children
|
||||
block.children = []
|
||||
block.add_xml_to_node(olx_node)
|
||||
block.children = children
|
||||
|
||||
# Now the block may have exported addtional data as files in
|
||||
# 'filesystem'. If so, store them:
|
||||
for item in filesystem.walk(): # pylint: disable=not-callable
|
||||
for unit_file in item.files:
|
||||
file_path = os.path.join(item.path, unit_file.name)
|
||||
with filesystem.open(file_path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
self.static_files.append(StaticFile(name=unit_file.name, data=data, url=None))
|
||||
# Recursively serialize the children:
|
||||
if block.has_children:
|
||||
for child in block.get_children():
|
||||
child_node = self.serialize_block(child)
|
||||
olx_node.append(child_node)
|
||||
return olx_node
|
||||
|
||||
def serialize_html_block(self, block) -> etree.Element:
|
||||
"""
|
||||
Special case handling for HTML blocks
|
||||
"""
|
||||
olx_node = etree.Element("html")
|
||||
if block.display_name:
|
||||
olx_node.attrib["display_name"] = block.display_name
|
||||
olx_node.text = etree.CDATA("\n" + block.data + "\n")
|
||||
return olx_node
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-16 23:45
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import opaque_keys.edx.django.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StagedContent',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('purpose', models.CharField(max_length=64)),
|
||||
('status', models.CharField(choices=[('loading', 'Loading'), ('ready', 'Ready'), ('expired', 'Expired'), ('error', 'Error')], max_length=20)),
|
||||
('block_type', models.CharField(help_text='\n What type of content is staged. Only OLX content is supported, and\n this field must be the same as the root tag of the OLX.\n e.g. "video" if a video is staged, or "vertical" for a unit.\n ', max_length=100)),
|
||||
('olx', models.TextField()),
|
||||
('display_name', models.CharField(max_length=1024)),
|
||||
('suggested_url_name', models.CharField(max_length=1024)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Staged Content',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserClipboard',
|
||||
fields=[
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='auth.user')),
|
||||
('source_usage_key', opaque_keys.edx.django.models.UsageKeyField(help_text='Original usage key/ID of the thing that is in the clipboard.', max_length=255)),
|
||||
('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content_staging.stagedcontent')),
|
||||
],
|
||||
),
|
||||
]
|
||||
126
openedx/core/djangoapps/content_staging/models.py
Normal file
126
openedx/core/djangoapps/content_staging/models.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Models for content staging (and clipboard)
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from opaque_keys.edx.django.models import UsageKeyField
|
||||
from opaque_keys.edx.keys import LearningContextKey
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class StagedContent(models.Model):
|
||||
"""
|
||||
Each StagedContent instance represents a "piece" of content (e.g. a single
|
||||
XBlock, or a single Unit, or a single Subsection of a course) that is not
|
||||
currently part of any course or library, but which is available to be copied
|
||||
into a course or library.
|
||||
|
||||
Use as a clipboard: for any given user, the most recent row with
|
||||
purpose=CLIPBOARD is the "current" clipboard content. But it can only be
|
||||
pasted if its status is READY.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = _("Staged Content")
|
||||
|
||||
class Status(models.TextChoices):
|
||||
""" The status of this staged content. """
|
||||
# LOADING: We are actively (asynchronously) writing the OLX and related data into the staging area.
|
||||
# It is not ready to be read.
|
||||
LOADING = "loading", _("Loading")
|
||||
# READY: The content is staged and ready to be read.
|
||||
READY = "ready", _("Ready")
|
||||
# The content has expired and this row can be deleted, along with any associated data.
|
||||
EXPIRED = "expired", _("Expired")
|
||||
# ERROR: The content could not be staged.
|
||||
ERROR = "error", _("Error")
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
# The user that created and owns this staged content. Only this user can read it.
|
||||
user = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
|
||||
created = models.DateTimeField(null=False, auto_now_add=True)
|
||||
# What this StagedContent is for (e.g. "clipboard" for clipboard)
|
||||
purpose = models.CharField(max_length=64)
|
||||
status = models.CharField(max_length=20, choices=Status.choices)
|
||||
|
||||
block_type = models.CharField(
|
||||
max_length=100,
|
||||
help_text=_("""
|
||||
What type of content is staged. Only OLX content is supported, and
|
||||
this field must be the same as the root tag of the OLX.
|
||||
e.g. "video" if a video is staged, or "vertical" for a unit.
|
||||
"""),
|
||||
)
|
||||
olx = models.TextField(null=False, blank=False)
|
||||
# The display name of whatever item is staged here, i.e. the root XBlock.
|
||||
display_name = models.CharField(max_length=1024)
|
||||
# A _suggested_ URL name to use for this content. Since this suggestion may already be in use, it's fine to generate
|
||||
# a new url_name instead.
|
||||
suggested_url_name = models.CharField(max_length=1024)
|
||||
|
||||
@property
|
||||
def olx_filename(self) -> str:
|
||||
""" Get a filename that can be used for the OLX content of this staged content """
|
||||
return f"{self.suggested_url_name}.xml"
|
||||
|
||||
def __str__(self):
|
||||
""" String representation of this instance """
|
||||
return f'Staged {self.block_type} block "{self.display_name}" ({self.status})'
|
||||
|
||||
|
||||
class UserClipboard(models.Model):
|
||||
"""
|
||||
Each user has a clipboard that can hold one item at a time, where an item
|
||||
is some OLX content that can be used in a course, such as an XBlock, a Unit,
|
||||
or a Subsection.
|
||||
"""
|
||||
# value of the "purpose" field on underlying StagedContent objects
|
||||
PURPOSE = "clipboard"
|
||||
|
||||
# The user that copied something. Clipboards are user-specific and
|
||||
# previously copied items are not kept.
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
||||
content = models.ForeignKey(StagedContent, on_delete=models.CASCADE)
|
||||
source_usage_key = UsageKeyField(
|
||||
max_length=255,
|
||||
help_text=_("Original usage key/ID of the thing that is in the clipboard."),
|
||||
)
|
||||
|
||||
@property
|
||||
def source_context_key(self) -> LearningContextKey:
|
||||
""" Get the context (course/library) that this was copied from """
|
||||
return self.source_usage_key.context_key
|
||||
|
||||
def get_source_context_title(self) -> str:
|
||||
""" Get the title of the source context, if any """
|
||||
if self.source_context_key.is_course:
|
||||
course_overview = get_course_overview_or_none(self.source_context_key)
|
||||
if course_overview:
|
||||
return course_overview.display_name_with_default
|
||||
# Just return the ID as the name, if it's empty or is not a course.
|
||||
return str(self.source_context_key)
|
||||
|
||||
def clean(self):
|
||||
""" Check that this model is being used correctly. """
|
||||
# These could probably be replaced with constraints in Django 4.1+
|
||||
if self.user.id != self.content.user.id:
|
||||
raise ValidationError("User ID mismatch.")
|
||||
if self.content.purpose != UserClipboard.PURPOSE:
|
||||
raise ValidationError(
|
||||
f"StagedContent.purpose must be '{UserClipboard.PURPOSE}' to use it as clipboard content."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" Save this model instance """
|
||||
# Enforce checks on save:
|
||||
self.full_clean()
|
||||
return super().save(*args, **kwargs)
|
||||
45
openedx/core/djangoapps/content_staging/serializers.py
Normal file
45
openedx/core/djangoapps/content_staging/serializers.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Serializers for the content libraries REST API
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import StagedContent
|
||||
|
||||
|
||||
class StagedContentSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for staged content. Doesn't include the OLX by default.
|
||||
"""
|
||||
olx_url = serializers.HyperlinkedIdentityField(view_name="staged-content-olx", lookup_field="id")
|
||||
|
||||
class Meta:
|
||||
model = StagedContent
|
||||
fields = [
|
||||
'id',
|
||||
'user',
|
||||
'created',
|
||||
'purpose',
|
||||
'status',
|
||||
'block_type',
|
||||
# We don't include OLX; it may be large. But we include the URL to retrieve it.
|
||||
'olx_url',
|
||||
'display_name',
|
||||
]
|
||||
|
||||
|
||||
class UserClipboardSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for the status of the user's clipboard
|
||||
"""
|
||||
content = StagedContentSerializer(allow_null=True)
|
||||
source_usage_key = serializers.CharField(allow_blank=True)
|
||||
# The title of the course that the content came from originally, if relevant
|
||||
source_context_title = serializers.CharField(allow_blank=True, source="get_source_context_title")
|
||||
|
||||
|
||||
class PostToClipboardSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for the POST request body when putting a new XBlock into the
|
||||
user's clipboard.
|
||||
"""
|
||||
usage_key = serializers.CharField(help_text="Usage key to copy into the clipboard")
|
||||
25
openedx/core/djangoapps/content_staging/tasks.py
Normal file
25
openedx/core/djangoapps/content_staging/tasks.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Celery tasks for Content Staging.
|
||||
"""
|
||||
from __future__ import annotations # for list[int] type
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from celery_utils.logged_task import LoggedTask
|
||||
|
||||
from .models import StagedContent, UserClipboard
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(base=LoggedTask)
|
||||
def delete_expired_clipboards(staged_content_ids: list[int]):
|
||||
"""
|
||||
A Celery task to delete StagedContent clipboard entries that are no longer
|
||||
relevant.
|
||||
"""
|
||||
for pk in staged_content_ids:
|
||||
# Due to signal handlers deleting asset file objects from S3 or similar,
|
||||
# this may be "slow" relative to database speed.
|
||||
StagedContent.objects.get(purpose=UserClipboard.PURPOSE, pk=pk).delete()
|
||||
log.info(f"Successfully deleted StagedContent entries ({','.join(str(x) for x in staged_content_ids)})")
|
||||
199
openedx/core/djangoapps/content_staging/tests/test_clipboard.py
Normal file
199
openedx/core/djangoapps/content_staging/tests/test_clipboard.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Tests for the clipboard functionality
|
||||
"""
|
||||
from textwrap import dedent
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from rest_framework.test import APIClient
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from xmodule.modulestore.tests.factories import ToyCourseFactory
|
||||
|
||||
|
||||
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
|
||||
|
||||
|
||||
class ClipboardTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test Clipboard functionality
|
||||
"""
|
||||
|
||||
def test_empty_clipboard(self):
|
||||
"""
|
||||
When a user has no content on their clipboard, we get an empty 200 response
|
||||
"""
|
||||
client = APIClient()
|
||||
client.login(username=self.user.username, password=self.user_password)
|
||||
response = client.get(CLIPBOARD_ENDPOINT)
|
||||
# We don't consider this a 404 error, it's a 200 with an empty response
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {
|
||||
"content": None,
|
||||
"source_usage_key": "",
|
||||
"source_context_title": ""
|
||||
})
|
||||
|
||||
def _setup_course(self):
|
||||
""" Set up the "Toy Course" and an APIClient for testing clipboard functionality. """
|
||||
# Setup:
|
||||
course_key = ToyCourseFactory.create().id # See xmodule/modulestore/tests/sample_courses.py
|
||||
client = APIClient()
|
||||
client.login(username=self.user.username, password=self.user_password)
|
||||
|
||||
# Initial conditions: clipboard is empty:
|
||||
response = client.get(CLIPBOARD_ENDPOINT)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["content"], None)
|
||||
|
||||
return (course_key, client)
|
||||
|
||||
def test_copy_video(self):
|
||||
"""
|
||||
Test copying a video from the course
|
||||
"""
|
||||
course_key, client = self._setup_course()
|
||||
|
||||
# Copy the video
|
||||
video_key = course_key.make_usage_key("video", "sample_video")
|
||||
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
|
||||
|
||||
# Validate the response:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response_data = response.json()
|
||||
self.assertEqual(response_data["source_usage_key"], str(video_key))
|
||||
self.assertEqual(response_data["source_context_title"], "Toy Course")
|
||||
self.assertEqual(response_data["content"], {**response_data["content"], **{
|
||||
"block_type": "video",
|
||||
# To ensure API stability, we are hard-coding these expected values:
|
||||
"purpose": "clipboard",
|
||||
"status": "ready",
|
||||
"display_name": "default", # Weird name but that's what defined in the toy course
|
||||
}})
|
||||
# Test the actual OLX in the clipboard:
|
||||
olx_url = response_data["content"]["olx_url"]
|
||||
olx_response = client.get(olx_url)
|
||||
self.assertEqual(olx_response.status_code, 200)
|
||||
self.assertEqual(olx_response.get("Content-Type"), "application/vnd.openedx.xblock.v1.video+xml")
|
||||
self.assertXmlEqual(
|
||||
olx_response.content.decode(),
|
||||
"""
|
||||
<video
|
||||
url_name="sample_video"
|
||||
display_name="default"
|
||||
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
|
||||
youtube_id_0_75="JMD_ifUUfsU"
|
||||
youtube_id_1_0="OEoXaMPEzfM"
|
||||
youtube_id_1_25="AKqURZnYqpk"
|
||||
youtube_id_1_5="DYpADpL7jAY"
|
||||
/>
|
||||
"""
|
||||
)
|
||||
|
||||
# Now if we GET the clipboard again, the GET response should exactly equal the last POST response:
|
||||
self.assertEqual(client.get(CLIPBOARD_ENDPOINT).json(), response_data)
|
||||
|
||||
def test_copy_html(self):
|
||||
"""
|
||||
Test copying an HTML from the course
|
||||
"""
|
||||
course_key, client = self._setup_course()
|
||||
|
||||
# Copy the video
|
||||
html_key = course_key.make_usage_key("html", "toyhtml")
|
||||
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
|
||||
|
||||
# Validate the response:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response_data = response.json()
|
||||
self.assertEqual(response_data["source_usage_key"], str(html_key))
|
||||
self.assertEqual(response_data["source_context_title"], "Toy Course")
|
||||
self.assertEqual(response_data["content"], {**response_data["content"], **{
|
||||
"block_type": "html",
|
||||
# To ensure API stability, we are hard-coding these expected values:
|
||||
"purpose": "clipboard",
|
||||
"status": "ready",
|
||||
"display_name": "Text", # Has no display_name set so we fallback to this default
|
||||
}})
|
||||
# Test the actual OLX in the clipboard:
|
||||
olx_url = response_data["content"]["olx_url"]
|
||||
olx_response = client.get(olx_url)
|
||||
self.assertEqual(olx_response.status_code, 200)
|
||||
self.assertEqual(olx_response.get("Content-Type"), "application/vnd.openedx.xblock.v1.html+xml")
|
||||
# For HTML, we really want to be sure that the OLX is serialized in this exact format (using CDATA), so we check
|
||||
# the actual string directly rather than using assertXmlEqual():
|
||||
self.assertEqual(olx_response.content.decode(), dedent("""
|
||||
<html display_name="Text"><![CDATA[
|
||||
<a href='/static/handouts/sample_handout.txt'>Sample</a>
|
||||
]]></html>
|
||||
""").lstrip())
|
||||
|
||||
# Now if we GET the clipboard again, the GET response should exactly equal the last POST response:
|
||||
self.assertEqual(client.get(CLIPBOARD_ENDPOINT).json(), response_data)
|
||||
|
||||
def test_copy_several_things(self):
|
||||
"""
|
||||
Test that the clipboard only holds one thing at a time.
|
||||
"""
|
||||
course_key, client = self._setup_course()
|
||||
|
||||
# Copy the video and validate the response:
|
||||
video_key = course_key.make_usage_key("video", "sample_video")
|
||||
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
video_clip_data = response.json()
|
||||
self.assertEqual(video_clip_data["source_usage_key"], str(video_key))
|
||||
self.assertEqual(video_clip_data["content"]["block_type"], "video")
|
||||
old_olx_url = video_clip_data["content"]["olx_url"]
|
||||
self.assertEqual(client.get(old_olx_url).status_code, 200)
|
||||
|
||||
# Now copy some HTML:
|
||||
html_key = course_key.make_usage_key("html", "toyhtml")
|
||||
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Now check the clipboard:
|
||||
response = client.get(CLIPBOARD_ENDPOINT)
|
||||
html_clip_data = response.json()
|
||||
self.assertEqual(html_clip_data["source_usage_key"], str(html_key))
|
||||
self.assertEqual(html_clip_data["content"]["block_type"], "html")
|
||||
|
||||
# The OLX link from the video will no longer work:
|
||||
self.assertEqual(client.get(old_olx_url).status_code, 404)
|
||||
|
||||
def test_no_course_permission(self):
|
||||
"""
|
||||
Test that a user without read access cannot copy items in a course
|
||||
"""
|
||||
course_key = ToyCourseFactory.create().id
|
||||
nonstaff_client = APIClient()
|
||||
nonstaff_username, nonstaff_password = self.create_non_staff_user()
|
||||
nonstaff_client.login(username=nonstaff_username, password=nonstaff_password)
|
||||
|
||||
# Try copying the video as a non-staff user:
|
||||
html_key = course_key.make_usage_key("html", "toyhtml")
|
||||
with self.allow_transaction_exception():
|
||||
response = nonstaff_client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = nonstaff_client.get(CLIPBOARD_ENDPOINT)
|
||||
self.assertEqual(response.json()["content"], None)
|
||||
|
||||
def test_no_stealing_clipboard_content(self):
|
||||
"""
|
||||
Test that a user cannot see another user's clipboard
|
||||
"""
|
||||
course_key, client = self._setup_course()
|
||||
nonstaff_client = APIClient()
|
||||
nonstaff_username, nonstaff_password = self.create_non_staff_user()
|
||||
nonstaff_client.login(username=nonstaff_username, password=nonstaff_password)
|
||||
|
||||
# The regular user copies something to their clipboard:
|
||||
html_key = course_key.make_usage_key("html", "toyhtml")
|
||||
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
|
||||
# Then another user tries to get the OLX:
|
||||
olx_url = response.json()["content"]["olx_url"]
|
||||
response = nonstaff_client.get(olx_url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool:
|
||||
""" Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """
|
||||
self.assertEqual(ElementTree.canonicalize(xml_str_a), ElementTree.canonicalize(xml_str_b))
|
||||
13
openedx/core/djangoapps/content_staging/urls.py
Normal file
13
openedx/core/djangoapps/content_staging/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Studio URL configuration for Content Staging (& Clipboard)
|
||||
"""
|
||||
from django.urls import path, include
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('api/content-staging/v1/', include([
|
||||
path('staged-content/<int:id>/olx', views.StagedContentOLXEndpoint.as_view(), name="staged-content-olx"),
|
||||
path('clipboard/', views.ClipboardEndpoint.as_view()),
|
||||
])),
|
||||
]
|
||||
152
openedx/core/djangoapps/content_staging/views.py
Normal file
152
openedx/core/djangoapps/content_staging/views.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
REST API views for content staging
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.decorators import method_decorator
|
||||
import edx_api_doc_tools as apidocs
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from common.djangoapps.student.auth import has_studio_read_access
|
||||
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from xmodule import block_metadata_utils
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .block_serializer import XBlockSerializer
|
||||
from .models import StagedContent, UserClipboard
|
||||
from .serializers import UserClipboardSerializer, PostToClipboardSerializer
|
||||
from .tasks import delete_expired_clipboards
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class StagedContentOLXEndpoint(APIView):
|
||||
"""
|
||||
API Endpoint to get the OLX of any given StagedContent.
|
||||
"""
|
||||
|
||||
def get(self, request, id): # pylint: disable=redefined-builtin
|
||||
"""
|
||||
Get the OLX of the given StagedContent object.
|
||||
"""
|
||||
staged_content = get_object_or_404(StagedContent, pk=id)
|
||||
if staged_content.user.id != request.user.id:
|
||||
raise PermissionDenied("Users can only access their own staged content")
|
||||
if staged_content.status != StagedContent.Status.READY:
|
||||
# If the status is LOADING, the OLX may not be generated/valid yet.
|
||||
# If the status is ERROR or EXPIRED, this row is no longer usable.
|
||||
raise NotFound("The requested content is not available.")
|
||||
return HttpResponse(staged_content.olx, headers={
|
||||
"Content-Type": f"application/vnd.openedx.xblock.v1.{staged_content.block_type}+xml",
|
||||
"Content-Disposition": f'attachment; filename="{staged_content.olx_filename}"',
|
||||
})
|
||||
|
||||
|
||||
@method_decorator(transaction.non_atomic_requests, name='dispatch')
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class ClipboardEndpoint(APIView):
|
||||
"""
|
||||
API Endpoint that can be used to get the status of the current user's
|
||||
clipboard or to POST some content to the clipboard.
|
||||
"""
|
||||
|
||||
@apidocs.schema(
|
||||
responses={
|
||||
200: UserClipboardSerializer,
|
||||
}
|
||||
)
|
||||
def get(self, request):
|
||||
"""
|
||||
Get the detailed status of the user's clipboard. This does not return the OLX.
|
||||
"""
|
||||
try:
|
||||
clipboard = UserClipboard.objects.get(user=request.user.id)
|
||||
except UserClipboard.DoesNotExist:
|
||||
# This user does not have any content on their clipboard.
|
||||
return Response({"content": None, "source_usage_key": "", "source_context_title": ""})
|
||||
serializer = UserClipboardSerializer(clipboard, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@apidocs.schema(
|
||||
body=PostToClipboardSerializer,
|
||||
responses={
|
||||
200: UserClipboardSerializer,
|
||||
403: "You do not have permission to read the specified usage key.",
|
||||
404: "The requested usage key does not exist.",
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
"""
|
||||
Put some piece of content into the user's clipboard.
|
||||
"""
|
||||
# Check if the content exists and the user has permission to read it.
|
||||
# Parse the usage key:
|
||||
try:
|
||||
usage_key = UsageKey.from_string(request.data["usage_key"])
|
||||
except (ValueError, InvalidKeyError):
|
||||
raise ValidationError('Invalid usage key') # lint-amnesty, pylint: disable=raise-missing-from
|
||||
if usage_key.block_type in ('course', 'chapter', 'sequential'):
|
||||
raise ValidationError('Requested XBlock tree is too large')
|
||||
course_key = usage_key.context_key
|
||||
if not isinstance(course_key, CourseLocator):
|
||||
# In the future, we'll support libraries too but for now we don't.
|
||||
raise ValidationError('Invalid usage key: not a modulestore course')
|
||||
# Make sure the user has permission on that course
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
raise PermissionDenied("You must be a member of the course team in Studio to export OLX using this API.")
|
||||
|
||||
# Get the OLX of the content
|
||||
try:
|
||||
block = modulestore().get_item(usage_key)
|
||||
except ItemNotFoundError as exc:
|
||||
raise NotFound("The requested usage key does not exist.") from exc
|
||||
block_data = XBlockSerializer(block)
|
||||
|
||||
expired_ids = []
|
||||
with transaction.atomic():
|
||||
# Mark all of the user's existing StagedContent rows as EXPIRED
|
||||
to_expire = StagedContent.objects.filter(
|
||||
user=request.user,
|
||||
purpose=UserClipboard.PURPOSE,
|
||||
).exclude(
|
||||
status=StagedContent.Status.EXPIRED,
|
||||
)
|
||||
for sc in to_expire:
|
||||
expired_ids.append(sc.id)
|
||||
sc.status = StagedContent.Status.EXPIRED
|
||||
sc.save()
|
||||
# Insert a new StagedContent row for this
|
||||
staged_content = StagedContent.objects.create(
|
||||
user=request.user,
|
||||
purpose=UserClipboard.PURPOSE,
|
||||
status=StagedContent.Status.READY,
|
||||
block_type=usage_key.block_type,
|
||||
olx=block_data.olx_str,
|
||||
display_name=block_metadata_utils.display_name_with_default(block),
|
||||
suggested_url_name=usage_key.block_id,
|
||||
)
|
||||
(clipboard, _created) = UserClipboard.objects.update_or_create(user=request.user, defaults={
|
||||
"content": staged_content,
|
||||
"source_usage_key": usage_key,
|
||||
})
|
||||
# Return the current clipboard exactly as if GET was called:
|
||||
serializer = UserClipboardSerializer(clipboard, context={"request": request})
|
||||
# Log an event so we can analyze how this feature is used:
|
||||
log.info(f"Copied {usage_key.block_type} component \"{usage_key}\" to their clipboard.")
|
||||
# Enqueue a (potentially slow) task to delete the old staged content
|
||||
try:
|
||||
delete_expired_clipboards.delay(expired_ids)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}")
|
||||
# Return the response:
|
||||
return Response(serializer.data)
|
||||
1
setup.py
1
setup.py
@@ -157,6 +157,7 @@ setup(
|
||||
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
|
||||
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",
|
||||
"content_libraries = openedx.core.djangoapps.content_libraries.apps:ContentLibrariesConfig",
|
||||
"content_staging = openedx.core.djangoapps.content_staging.apps:ContentStagingAppConfig",
|
||||
# Importing an LMS app into the Studio process is not a good
|
||||
# practice. We're ignoring this for Discussions here because its
|
||||
# placement in LMS is a historical artifact. The eventual goal is to
|
||||
|
||||
Reference in New Issue
Block a user